diff --git a/public/assets/footer-1x4.png b/public/assets/footer-1x4.png index 9103489..a116f79 100644 Binary files a/public/assets/footer-1x4.png and b/public/assets/footer-1x4.png differ diff --git a/public/assets/header-1x4.png b/public/assets/header-1x4.png index b592174..5882602 100644 Binary files a/public/assets/header-1x4.png and b/public/assets/header-1x4.png differ diff --git a/src/views/PrintStepView.vue b/src/views/PrintStepView.vue index ed1365b..3972614 100644 --- a/src/views/PrintStepView.vue +++ b/src/views/PrintStepView.vue @@ -40,7 +40,7 @@ const startCountdown = () => { // หลังจากแสดงผล 3 วินาที ให้ไปหน้า pickup setTimeout(() => { - router.push('/pickup') + // router.push('/pickup') }, 3000) } }, 1000) diff --git a/src/views/ShootingView.vue b/src/views/ShootingView.vue index f234215..757ed01 100644 --- a/src/views/ShootingView.vue +++ b/src/views/ShootingView.vue @@ -49,6 +49,116 @@ const stopCamera = () => { } } +const applyFilmEffect = (context: CanvasRenderingContext2D, width: number, height: number, volume: number = 5) => { + const imageData = context.getImageData(0, 0, width, height); + const data = imageData.data; + + // 1. แปลง Volume (1-10) เป็นค่าที่ใช้งานได้จริง + // contrast: ยิ่งเยอะ ภาพยิ่งคมเข้ม (แนะนำช่วง 0 ถึง 50) + const contrast = (volume / 10) * 50; + const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast)); + + // tint: ปรับโทนสี (Film มักจะอมแดง/เขียว ลดฟ้า) + const redBoost = volume * 2; // เพิ่มแดงนิดหน่อย + const greenBoost = volume * 1; // เพิ่มเขียวจางๆ + const blueCut = volume * 3; // ลดฟ้าลงเพื่อให้ภาพดูอุ่น (Warm tone) + + // grain: ความแรงของเม็ดเกรน + const grainStrength = volume * 1.5; + + for (let i = 0; i < data.length; i += 4) { + let r = data[i]; + let g = data[i + 1]; + let b = data[i + 2]; + + // --- STEP 1: Apply Contrast (ทำให้ภาพไม่แบน) --- + // สูตร Contrast มาตรฐาน + r = contrastFactor * (r - 128) + 128; + g = contrastFactor * (g - 128) + 128; + b = contrastFactor * (b - 128) + 128; + + // --- STEP 2: Color Grading (ปรับโทนฟิล์ม) --- + // ฟิล์ม Kodak/Fuji มักจะไม่ใช่ Sepia ล้วน แต่คือการจูน Channel + r += redBoost; + g += greenBoost; + b -= blueCut; + + // --- STEP 3: Add Grain (เม็ดเกรน) --- + // ใช้เทคนิคสุ่มทั้งบวกและลบ เพื่อไม่ให้ความสว่างรวมเพี้ยน + const grain = (Math.random() - 0.5) * grainStrength; + + // ผสมเกรนลงไป + r += grain; + g += grain; + b += grain; + + // --- STEP 4: Clamp values (กันค่าเกิน 0-255) --- + // ถ้าไม่กันค่า จะเกิดจุดสีประหลาดๆ เมื่อค่าทะลุ 255 หรือต่ำกว่า 0 + data[i] = Math.max(0, Math.min(255, r)); + data[i + 1] = Math.max(0, Math.min(255, g)); + data[i + 2] = Math.max(0, Math.min(255, b)); + } + + context.putImageData(imageData, 0, 0); + + // --- STEP 5: (Optional) Add Vignette (ขอบมืด) --- + // การวาด Gradient ทับ เร็วกว่าและเนียนกว่าการคำนวณทีละ pixel + if (volume > 2) { + addVignette(context, width, height, volume); + } +}; + +// ฟังก์ชันเสริมสำหรับทำขอบมืด (Vignette) +const addVignette = (ctx: CanvasRenderingContext2D, w: number, h: number, strength: number) => { + const opacity = (strength / 10) * 0.6; // สูงสุดที่ 0.6 opacity + const radius = Math.max(w, h) * 0.8; + + // สร้าง Gradient วงกลมจากตรงกลาง + const gradient = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, radius); + gradient.addColorStop(0.5, "rgba(0,0,0,0)"); // ตรงกลางใส + gradient.addColorStop(1, `rgba(0,0,0,${opacity})`); // ขอบดำ + + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); +}; + +const drawDateStamp = (context: CanvasRenderingContext2D, width: number, height: number) => { + const now = new Date(); + + // จัดรูปแบบวันที่แบบกล้องฟิล์ม (เช่น '98 1 25 หรือ 25 1 '98) + // ปีเอาแค่ 2 หลักท้าย + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); // หรือจะใช้เลขเดียวแบบกล้องเก่าๆ ก็ได้ + const day = now.getDate().toString().padStart(2, '0'); + + const dateString = `'${year} ${month} ${day}`; // รูปแบบ: '24 01 18 + + // ตั้งค่า Font (Digital-like) + // แนะนำให้หา Font ชื่อ 'Digital-7' หรือ 'DS-Digital' มาลงจะเหมือนมาก + // แต่ถ้าไม่มี ใช้ Arial หรือ Courier New ก็พอไหวครับ + const fontSize = height * 0.05; // ขนาด 5% ของความสูงภาพ + context.font = `bold ${fontSize}px "Courier New", monospace`; + + // สีส้มอมแดง (Classic Date Stamp Color) + context.fillStyle = "#ff5e3a"; + + // เพิ่มเงาเรืองแสงนิดๆ ให้ดูเหมือนไฟ LED ที่ยิงลงฟิล์ม + context.shadowColor = "#ff0000"; + context.shadowBlur = 10; + + // ตำแหน่ง: มุมขวาล่าง + const paddingX = width * 0.05; + const paddingY = height * 0.03; + const x = width - context.measureText(dateString).width - paddingX; + const y = height - paddingY; + + context.fillText(dateString, x, y); + + // Reset shadow เพื่อไม่ให้กวนการวาดส่วนอื่น + context.shadowBlur = 0; +}; + const captureSinglePhoto = () => { if (!videoRef.value || !canvasRef.value || isCapturing.value) return @@ -95,6 +205,10 @@ const captureSinglePhoto = () => { 0, 0, finalWidth, finalHeight // ตำแหน่งและขนาดใน canvas ) + // Apply film effect with default volume + applyFilmEffect(context, finalWidth, finalHeight, 5); + drawDateStamp(context, finalWidth, finalHeight); + // แปลงเป็น base64 ด้วย quality 0.7 เพื่อลดขนาด const photoDataUrl = canvas.toDataURL('image/jpeg', 0.7) photos.value.push(photoDataUrl) diff --git a/src/views/UploadView.vue b/src/views/UploadView.vue index 2cb3969..8cbb601 100644 --- a/src/views/UploadView.vue +++ b/src/views/UploadView.vue @@ -8,59 +8,179 @@ const fileInputRef = ref() const totalPhotos = 4 +const applyFilmEffect = (context: CanvasRenderingContext2D, width: number, height: number, volume: number = 5) => { + const imageData = context.getImageData(0, 0, width, height); + const data = imageData.data; + + // 1. แปลง Volume (1-10) เป็นค่าที่ใช้งานได้จริง + // contrast: ยิ่งเยอะ ภาพยิ่งคมเข้ม (แนะนำช่วง 0 ถึง 50) + const contrast = (volume / 10) * 50; + const contrastFactor = (259 * (contrast + 255)) / (255 * (259 - contrast)); + + // tint: ปรับโทนสี (Film มักจะอมแดง/เขียว ลดฟ้า) + const redBoost = volume * 2; // เพิ่มแดงนิดหน่อย + const greenBoost = volume * 1; // เพิ่มเขียวจางๆ + const blueCut = volume * 3; // ลดฟ้าลงเพื่อให้ภาพดูอุ่น (Warm tone) + + // grain: ความแรงของเม็ดเกรน + const grainStrength = volume * 1.5; + + for (let i = 0; i < data.length; i += 4) { + let r = data[i]; + let g = data[i + 1]; + let b = data[i + 2]; + + // --- STEP 1: Apply Contrast (ทำให้ภาพไม่แบน) --- + // สูตร Contrast มาตรฐาน + r = contrastFactor * (r - 128) + 128; + g = contrastFactor * (g - 128) + 128; + b = contrastFactor * (b - 128) + 128; + + // --- STEP 2: Color Grading (ปรับโทนฟิล์ม) --- + // ฟิล์ม Kodak/Fuji มักจะไม่ใช่ Sepia ล้วน แต่คือการจูน Channel + r += redBoost; + g += greenBoost; + b -= blueCut; + + // --- STEP 3: Add Grain (เม็ดเกรน) --- + // ใช้เทคนิคสุ่มทั้งบวกและลบ เพื่อไม่ให้ความสว่างรวมเพี้ยน + const grain = (Math.random() - 0.5) * grainStrength; + + // ผสมเกรนลงไป + r += grain; + g += grain; + b += grain; + + // --- STEP 4: Clamp values (กันค่าเกิน 0-255) --- + // ถ้าไม่กันค่า จะเกิดจุดสีประหลาดๆ เมื่อค่าทะลุ 255 หรือต่ำกว่า 0 + data[i] = Math.max(0, Math.min(255, r)); + data[i + 1] = Math.max(0, Math.min(255, g)); + data[i + 2] = Math.max(0, Math.min(255, b)); + } + + context.putImageData(imageData, 0, 0); + + // --- STEP 5: (Optional) Add Vignette (ขอบมืด) --- + // การวาด Gradient ทับ เร็วกว่าและเนียนกว่าการคำนวณทีละ pixel + if (volume > 2) { + addVignette(context, width, height, volume); + } +}; + +// ฟังก์ชันเสริมสำหรับทำขอบมืด (Vignette) +const addVignette = (ctx: CanvasRenderingContext2D, w: number, h: number, strength: number) => { + const opacity = (strength / 10) * 0.6; // สูงสุดที่ 0.6 opacity + const radius = Math.max(w, h) * 0.8; + + // สร้าง Gradient วงกลมจากตรงกลาง + const gradient = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, radius); + gradient.addColorStop(0.5, "rgba(0,0,0,0)"); // ตรงกลางใส + gradient.addColorStop(1, `rgba(0,0,0,${opacity})`); // ขอบดำ + + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); +}; + +const drawDateStamp = (context: CanvasRenderingContext2D, width: number, height: number) => { + const now = new Date(); + + // จัดรูปแบบวันที่แบบกล้องฟิล์ม (เช่น '98 1 25 หรือ 25 1 '98) + // ปีเอาแค่ 2 หลักท้าย + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); // หรือจะใช้เลขเดียวแบบกล้องเก่าๆ ก็ได้ + const day = now.getDate().toString().padStart(2, '0'); + + const dateString = `'${year} ${month} ${day}`; // รูปแบบ: '24 01 18 + + // ตั้งค่า Font (Digital-like) + // แนะนำให้หา Font ชื่อ 'Digital-7' หรือ 'DS-Digital' มาลงจะเหมือนมาก + // แต่ถ้าไม่มี ใช้ Arial หรือ Courier New ก็พอไหวครับ + const fontSize = height * 0.05; // ขนาด 5% ของความสูงภาพ + context.font = `bold ${fontSize}px "Courier New", monospace`; + + // สีส้มอมแดง (Classic Date Stamp Color) + context.fillStyle = "#ff5e3a"; + + // เพิ่มเงาเรืองแสงนิดๆ ให้ดูเหมือนไฟ LED ที่ยิงลงฟิล์ม + context.shadowColor = "#ff0000"; + context.shadowBlur = 10; + + // ตำแหน่ง: มุมขวาล่าง + const paddingX = width * 0.05; + const paddingY = height * 0.03; + const x = width - context.measureText(dateString).width - paddingX; + const y = height - paddingY; + + context.fillText(dateString, x, y); + + // Reset shadow เพื่อไม่ให้กวนการวาดส่วนอื่น + context.shadowBlur = 0; +}; + const cropImageTo34 = (imageSrc: string): Promise => { return new Promise((resolve) => { - const img = new Image() + const img = new Image(); img.onload = () => { - const canvas = document.createElement('canvas') - const context = canvas.getContext('2d') + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); if (!context) { - resolve(imageSrc) - return + resolve(imageSrc); + return; } - const imgWidth = img.width - const imgHeight = img.height + const imgWidth = img.width; + const imgHeight = img.height; // คำนวณขนาดสำหรับ 3:4 aspect ratio - const targetAspectRatio = 3/4 // width:height = 3:4 - let cropWidth, cropHeight, cropX, cropY + const targetAspectRatio = 3 / 4; // width:height = 3:4 + let cropWidth, cropHeight, cropX, cropY; if (imgWidth / imgHeight > targetAspectRatio) { // ภาพกว้างกว่าที่ต้องการ - ครอบด้านข้าง - cropHeight = imgHeight - cropWidth = imgHeight * targetAspectRatio - cropX = (imgWidth - cropWidth) / 2 - cropY = 0 + cropHeight = imgHeight; + cropWidth = imgHeight * targetAspectRatio; + cropX = (imgWidth - cropWidth) / 2; + cropY = 0; } else { // ภาพสูงกว่าที่ต้องการ - ครอบด้านบน/ล่าง - cropWidth = imgWidth - cropHeight = imgWidth / targetAspectRatio - cropX = 0 - cropY = (imgHeight - cropHeight) / 2 + cropWidth = imgWidth; + cropHeight = imgWidth / targetAspectRatio; + cropX = 0; + cropY = (imgHeight - cropHeight) / 2; } // ตั้งค่าขนาด canvas เป็น 3:4 (360x480) - ลดขนาดเพื่อป้องกัน localStorage quota exceeded - const finalWidth = 360 - const finalHeight = 480 + const finalWidth = 360; + const finalHeight = 480; - canvas.width = finalWidth - canvas.height = finalHeight + canvas.width = finalWidth; + canvas.height = finalHeight; // วาดภาพที่ครอบแล้วไปยัง canvas context.drawImage( img, - cropX, cropY, cropWidth, cropHeight, // ตำแหน่งและขนาดที่ครอบจากภาพต้นฉบับ - 0, 0, finalWidth, finalHeight // ตำแหน่งและขนาดใน canvas - ) + cropX, + cropY, + cropWidth, + cropHeight, // ตำแหน่งและขนาดที่ครอบจากภาพต้นฉบับ + 0, + 0, + finalWidth, + finalHeight // ตำแหน่งและขนาดใน canvas + ); + + // Apply film effect with adjustable volume + applyFilmEffect(context, finalWidth, finalHeight, 7); // Default volume = 5 + drawDateStamp(context, finalWidth, finalHeight); // แปลงเป็น base64 ด้วย quality 0.7 เพื่อลดขนาด - const croppedDataUrl = canvas.toDataURL('image/jpeg', 0.7) - resolve(croppedDataUrl) - } - img.src = imageSrc - }) + const croppedDataUrl = canvas.toDataURL('image/jpeg', 0.7); + resolve(croppedDataUrl); + }; + img.src = imageSrc; + }); } const triggerFileInput = () => {