- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려 기존 이미지 첨부 경로( → resolveImageContent)로 모델에 전달. 음성 통화 STT도 같은 전송 경로라 그대로 동작 - 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부 - 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장 - handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
115 lines
5.3 KiB
JavaScript
115 lines
5.3 KiB
JavaScript
// ── 채팅 라이브 카메라 ───────────────────────────────────────────────────
|
|
// 📷 버튼으로 카메라 패널(미리보기)을 켜 두면, 메시지를 보낼 때마다(타이핑·음성 통화 STT 공통)
|
|
// 현재 프레임을 찍어 이미지로 올리고 메시지에 자동 첨부한다. 서버는 기존 이미지 첨부와 같은
|
|
// 경로(/api/upload/image →  → resolveImageContent)로 모델에 시각 입력을 넘긴다.
|
|
// 프레임 alt는 CAMERA_ALT("📷…")로 시작 — 서버가 이전 턴의 카메라 프레임을 텍스트로 접어 비용 누적을 막는다.
|
|
(function () {
|
|
var CAMERA_ALT = '📷 카메라';
|
|
var MAX_SIDE = 768; // 긴 변 기준 다운스케일 (모델 입력 비용·업로드 크기 절감)
|
|
var JPEG_QUALITY = 0.72;
|
|
var stream = null;
|
|
var facing = 'environment'; // 폰은 후면 우선, 후면이 없으면 브라우저가 알아서 대체
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
// 메인 채팅은 authHeaders(), 작업실 페이지는 authH() — 둘 다 지원
|
|
function authHdr() {
|
|
if (typeof window.authHeaders === 'function') return window.authHeaders();
|
|
if (typeof window.authH === 'function') return window.authH();
|
|
return {};
|
|
}
|
|
function insertText(md) {
|
|
if (typeof window.insertIntoChatInput === 'function') { window.insertIntoChatInput(md); return; }
|
|
var el = $('chat-input');
|
|
if (!el) return;
|
|
el.value = el.value ? el.value + '\n' + md : md;
|
|
el.dispatchEvent(new Event('input'));
|
|
el.focus();
|
|
}
|
|
|
|
function setOpenUI(open) {
|
|
var panel = $('camera-panel'), btn = $('camera-btn');
|
|
if (panel) panel.style.display = open ? 'flex' : 'none';
|
|
if (btn) { btn.classList.toggle('recording', open); btn.classList.toggle('on', open); btn.title = open ? '카메라 끄기' : '카메라 켜기 (메시지마다 현재 화면 첨부)'; }
|
|
}
|
|
|
|
async function openCamera() {
|
|
var video = $('camera-video');
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
|
audio: false
|
|
});
|
|
} catch (e) {
|
|
alert('카메라 권한이 필요합니다: ' + e.message);
|
|
stream = null;
|
|
return;
|
|
}
|
|
video.srcObject = stream;
|
|
try { await video.play(); } catch (e) { /* autoplay 정책 — muted+playsinline이면 보통 통과 */ }
|
|
setOpenUI(true);
|
|
}
|
|
|
|
function closeCamera() {
|
|
if (stream) { try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} }
|
|
stream = null;
|
|
var video = $('camera-video');
|
|
if (video) video.srcObject = null;
|
|
setOpenUI(false);
|
|
}
|
|
|
|
window.toggleCamera = function () { if (stream) closeCamera(); else openCamera(); };
|
|
|
|
window.cameraFlip = async function () {
|
|
facing = facing === 'environment' ? 'user' : 'environment';
|
|
if (stream) { closeCamera(); await openCamera(); }
|
|
};
|
|
|
|
window.cameraClose = closeCamera;
|
|
window.cameraIsOn = function () { return !!stream; };
|
|
|
|
function captureBlob() {
|
|
var video = $('camera-video');
|
|
if (!stream || !video || !video.videoWidth) return Promise.resolve(null);
|
|
var w = video.videoWidth, h = video.videoHeight;
|
|
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
|
|
var cw = Math.round(w * scale), ch = Math.round(h * scale);
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = cw; canvas.height = ch;
|
|
canvas.getContext('2d').drawImage(video, 0, 0, cw, ch);
|
|
return new Promise(function (resolve) { canvas.toBlob(resolve, 'image/jpeg', JPEG_QUALITY); });
|
|
}
|
|
|
|
window.cameraCaptureBlob = function () { return captureBlob(); };
|
|
|
|
// 현재 프레임을 서버에 올리고 채팅용 마크다운을 돌려준다. 실패하면 '' (메시지는 그대로 전송된다).
|
|
async function uploadFrame() {
|
|
var blob = await captureBlob();
|
|
if (!blob) return '';
|
|
var form = new FormData();
|
|
form.append('image', blob, 'camera-' + Date.now() + '.jpg');
|
|
var res = await fetch('/api/upload/image', { method: 'POST', headers: authHdr(), body: form });
|
|
var data = await res.json();
|
|
if (data && data.success && data.path) return '';
|
|
return '';
|
|
}
|
|
|
|
// 📸 버튼: 지금 화면을 입력창에 이미지로 넣는다 (자동 첨부와 별개로 수동 첨부)
|
|
window.cameraSnapAttach = async function () {
|
|
try {
|
|
var md = await uploadFrame();
|
|
if (md) insertText(md);
|
|
else alert('카메라 화면을 아직 읽을 수 없습니다. 잠시 후 다시 시도하세요.');
|
|
} catch (e) { alert('촬영 오류: ' + e.message); }
|
|
};
|
|
|
|
// sendChat이 전송 직전에 호출 — 라이브 자동 첨부가 켜져 있으면 프레임 마크다운, 아니면 ''
|
|
window.cameraFrameForSend = async function () {
|
|
var chk = $('camera-auto');
|
|
if (!stream || (chk && !chk.checked)) return '';
|
|
try { return await uploadFrame(); } catch (e) { console.warn('[camera] frame upload failed:', e.message); return ''; }
|
|
};
|
|
|
|
// 탭을 떠나면 카메라 스트림 정리 (모바일에서 카메라 표시등이 계속 켜진 채 남는 것 방지)
|
|
window.addEventListener('pagehide', closeCamera);
|
|
})();
|