Files
homeclaw/web-ui/js/app/camera.js
T
kimandClaude Sonnet 5 57cf65fd7e feat: 채팅·작업실 라이브 카메라 — 메시지마다 현재 화면 자동 첨부
- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려
  기존 이미지 첨부 경로(![📷…](/api/files/…) → resolveImageContent)로 모델에 전달. 음성 통화 STT도
  같은 전송 경로라 그대로 동작
- 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부
- 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장
- handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 20:37:16 +09:00

115 lines
5.3 KiB
JavaScript

// ── 채팅 라이브 카메라 ───────────────────────────────────────────────────
// 📷 버튼으로 카메라 패널(미리보기)을 켜 두면, 메시지를 보낼 때마다(타이핑·음성 통화 STT 공통)
// 현재 프레임을 찍어 이미지로 올리고 메시지에 자동 첨부한다. 서버는 기존 이미지 첨부와 같은
// 경로(/api/upload/image → ![alt](/api/files/..) → 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 '![' + CAMERA_ALT + '](/api/files/' + data.path + ')';
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);
})();