feat(report): add annotated wrinkle views and restore capture countdown

This commit is contained in:
xsl
2026-10-15 19:45:21 +08:00
parent 2d80f6dfd0
commit 757b96dfad
23 changed files with 396 additions and 75 deletions
+103 -18
View File
@@ -516,6 +516,7 @@
startCamera();
}
function stopCamera(keepFrozen) {
clearCaptureCountdown();
camSeqCam++; // 使进行中的 getUserMedia 回调失效
clearInterval(camReadyTimer); clearTimeout(camOpenTimer);
camReadyTimer = camOpenTimer = null;
@@ -701,14 +702,14 @@
cur = null;
if (c.src) { c.src.onended = null; try { c.src.stop(); } catch (e) {} }
}
function start(id, kind) {
function start(id, kind, onStart) {
stopCur();
lastAt[id] = Date.now();
var token = {};
cur = { id: id, kind: kind, token: token, src: null, end: 0 };
load(id).then(function (buf) {
if (!cur || cur.token !== token) return; // 加载期间已被打断
if (!buf || !ctx) { cur = null; drain(); return; }
if (!buf || !ctx) { if (onStart) onStart(3000); cur = null; drain(); return; }
var src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
@@ -716,11 +717,12 @@
cur.end = Date.now() + buf.duration * 1000;
src.onended = function () { if (cur && cur.token === token) { cur = null; drain(); } };
src.start(0);
if (onStart) onStart(buf.duration * 1000);
});
}
function drain() {
if (cur) return;
if (queue.length) { start(queue.shift(), 'flow'); return; }
if (queue.length) { var next = queue.shift(); start(next.id, 'flow', next.onStart); return; }
if (pendingHint) { var h = pendingHint; pendingHint = null; start(h, 'hint'); return; }
var w = waiters;
waiters = [];
@@ -731,16 +733,16 @@
// 流程音是离散事件,每次都播(连拍的「拍摄完成」、取消后重来的倒计时)
function play(id, opts) {
opts = opts || {};
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) return;
if (!ctx || !id || ctx.state !== 'running' || !files || !files[id]) { if (opts.onStart) opts.onStart(3000); return; }
if (opts.hint) {
if (lastAt[id] && Date.now() - lastAt[id] < 4000) return;
if (busyWithFlow()) { pendingHint = id; return; }
start(id, 'hint');
return;
}
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow'); return; }
if (busyWithFlow()) { if (queue.indexOf(id) < 0) queue.push(id); return; }
start(id, 'flow'); // 在播的姿态提示让位给流程音
if (opts.cut) { queue = []; pendingHint = null; start(id, 'flow', opts.onStart); return; }
if (busyWithFlow()) { if (!queue.some(function(item) { return item.id === id; })) queue.push({ id:id, onStart:opts.onStart }); return; }
start(id, 'flow', opts.onStart); // 在播的姿态提示让位给流程音
}
function dropHint() { pendingHint = null; } // 提示已清空/换步:挂起的旧提示作废
// 流程切走:作废排队/挂起/等待;hard 连在播一并掐断(退出拍照、页面隐藏),否则让在播自然收尾(出报告)
@@ -1082,7 +1084,29 @@
}, hasNext ? NEXT_PAUSE_MS : 300);
}
// 白屏补光抓拍:全屏纯白瞬间点亮→约300ms 后抓全分辨率帧→保持300ms 再淡出(总 600–900ms)
var captureCountdownTimers = [];
function clearCaptureCountdown() {
captureCountdownTimers.forEach(clearTimeout);
captureCountdownTimers = [];
var el = $('#cam-countdown');
el.hidden = true; el.textContent = '';
}
function startCaptureCountdown(seq, duration, done) {
clearCaptureCountdown();
function tick(number) {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') { clearCaptureCountdown(); return; }
var el = $('#cam-countdown'), digit = document.createElement('span');
digit.className = 'cam-countdown-tick'; digit.textContent = String(number);
el.replaceChildren(digit); el.hidden = false;
}
tick(3);
[2,1].forEach(function(number,i) { captureCountdownTimers.push(setTimeout(function() { tick(number); }, duration * (i+1) / 3)); });
captureCountdownTimers.push(setTimeout(function() {
clearCaptureCountdown();
if (seq === camSeq && camLive && currentScreen === 's-cam') done();
}, duration));
}
// 白屏补光抓拍:数字倒计时和语音都结束后才点亮补光、抓帧。
function doCapture() {
if (shutterLock) return;
if (!camLive) return;
@@ -1093,8 +1117,19 @@
hideNudge();
var seq = camSeq;
voice.dropHint();
voice.play(VOICE.countdown);
var audioDone = false, visualDone = false, started = false;
function finishCountdown() { if (audioDone && visualDone) capture(); }
voice.play(VOICE.countdown, { onStart:function(duration) {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
started = true;
startCaptureCountdown(seq, duration, function() { visualDone = true; finishCountdown(); });
} });
voice.afterCurrent(function () {
audioDone = true;
if (!started) { started = true; startCaptureCountdown(seq, 3000, function() { visualDone = true; finishCountdown(); }); }
finishCountdown();
}, true);
function capture() {
if (seq !== camSeq || !camLive || currentScreen !== 's-cam') return;
var fill = $('#cam-fill');
fill.classList.add('on'); // .on 无过渡,瞬间点亮
@@ -1117,7 +1152,7 @@
});
}, 300);
}, 300);
}, true);
}
}
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
@@ -1200,7 +1235,7 @@
if (fx) {
// marks 坐标空间=fixture 照片空间:三连拍替换为 fixture 照片,保证可视化叠加对齐
ph[0] = fx.photos[0]; ph[1] = fx.photos[1]; ph[2] = fx.photos[2];
done(null, { result: fx.result, side: fx.side, face_rectangle: fx.face_rectangle });
done(null, { result: fx.result, side: fx.side, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle });
} else {
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
}
@@ -1270,8 +1305,8 @@
function setCat(key, cat) {
var dom = CAT_DOM[key];
// 设计稿分类卡只有话术正文:不展示 S 与档位徽章;缺数据分类以说明代正文
$(dom.tx).textContent = cat.P == null ? '本次未检出有效数据' : cat.text;
// 分类卡只展示有效话术正文;缺数据不在报告中提示重拍。
$(dom.tx).textContent = cat.P == null ? '' : cat.text;
}
/* ─── 报告页检测可视化(face_maps 图层 + marks 坐标叠加,数据驱动) ─── */
@@ -1350,6 +1385,17 @@
}
return out;
}
function vizWrinkleMarks(result) {
var outlines = result && result.wrinkle_mark;
if (!outlines || typeof outlines !== 'object') return [];
var normalized = {}, defs = [];
Object.keys(outlines).forEach(function(key) {
if (!/_wrinkle_outline$/.test(key) || !Array.isArray(outlines[key])) return;
normalized[key] = { polygon: [outlines[key]] };
defs.push({ key: key, color: '#6E9900' });
});
return vizCollectMarks(defs, normalized);
}
// marks 坐标空间 = 上传图片像素空间:按「画布自然宽 / 坐标空间宽」换算(画布=照片自然尺寸时比值为 1)
function vizScaleMarks(marks, scale) {
@@ -1395,6 +1441,7 @@
if (!photo) return; // 照片不可用:保留占位
var finish = function (overlays) {
if (epoch !== vizEpoch) return;
if (!overlays.some(Boolean) && opts && opts.fallbackMarks) marks = opts.fallbackMarks;
var crop = vizFaceCrop(photo.naturalWidth, photo.naturalHeight, opts && opts.faceRectangle);
var originalTop = crop.top;
crop = vizShiftContentDown(crop, opts && opts.faceRectangle, opts && opts.contentDown);
@@ -1450,6 +1497,7 @@
imgEl.src = cv.toDataURL('image/jpeg', 0.9); imgEl.hidden = false;
imgEl.dataset.crop = crop.focused ? 'face' : 'full';
imgEl.dataset.contentOffset = String((originalTop - crop.top) / crop.height);
imgEl.dataset.hasAnnotation = String(overlays.some(Boolean) || marks.length > 0);
imgEl.dataset.overlayAlpha = String(opts && typeof opts.alpha === 'number' ? opts.alpha : 1);
} catch (e) { /* 合成失败保留占位 */ }
};
@@ -1503,9 +1551,10 @@
}
if (cfg.overlay) { // 透明增强图层(皱纹):有照片则合成(可按 cfg.dim 压暗),无照片单挂图层;图层缺失 → 保留占位
var ov = vizMapURL(maps[cfg.overlay]);
if (!ov) continue;
var wrinkleMarks = key === 'wrinkles' && !ov ? vizWrinkleMarks(result) : [];
if (!ov && !frontPhoto) continue;
if (!frontPhoto) { imgEl.src = ov; imgEl.hidden = false; continue; }
vizCompose(frontPhoto, ov, [], imgEl, { dim: cfg.dim, faceRectangle: rectangle });
vizCompose(frontPhoto, ov, wrinkleMarks, imgEl, { dim: ov || wrinkleMarks.length ? cfg.dim : 0, faceRectangle: rectangle, contentDown: key === 'wrinkles' ? .09 : 0, fallbackMarks: key === 'wrinkles' ? vizWrinkleMarks(result) : null });
continue;
}
// 照片类(轮廓/光滑/痘痘):无照片 → 保留占位;有坐标则 canvas 叠加,无坐标纯照片
@@ -1542,6 +1591,37 @@
});
refreshHeight();
}
var wrinklePhotoEpoch = 0;
function renderWrinkleSides(reportPhotos, payload) {
var epoch = ++wrinklePhotoEpoch, wrap = $('#wrinkle-sides'), note = $('#wrinkle-layer-note');
function refresh() {
var visible = Array.from(wrap.querySelectorAll('figure:not([hidden])'));
wrap.hidden = !visible.length;
note.hidden = !visible.some(function(f) { return f.querySelector('img').dataset.hasAnnotation !== 'true'; });
var card = wrap.closest('.card-detail'), state = detailSwipes.get(card);
if (state) state.height(Number(card.querySelector('.dtab.active').dataset.tab));
}
wrap.hidden = note.hidden = true;
['left', 'right'].forEach(function(view, i) {
var img = $('#wrinkle-' + view), figure = img.closest('figure');
img.onload = img.onerror = null; img.removeAttribute('src');
img.dataset.hasAnnotation = 'false'; img.draggable = false; figure.hidden = true;
var src = reportPhotos && reportPhotos[i + 1];
if (!src) return;
// Only this photograph's own API data may be used. The front-face map
// is NEVER a fallback for a side photo.
var side = payload && payload.wrinkle_views && payload.wrinkle_views[view];
if (!side || !side.result) side = payload && payload.side && payload.side[view];
var result = side && (side.result || side);
var overlay = vizMapURL(result && result.face_maps && result.face_maps.texture_enhanced_lines);
var marks = overlay ? [] : vizWrinkleMarks(result);
img.onload = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = false; refresh(); } };
img.onerror = function() { if (epoch === wrinklePhotoEpoch) { figure.hidden = true; img.removeAttribute('src'); refresh(); } };
if (overlay || marks.length) vizCompose(src, overlay, marks, img, { dim: .55, fallbackMarks: vizWrinkleMarks(result) });
else img.src = src;
});
refresh();
}
function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) {
renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]);
reportOwner = AccountClient.user() && AccountClient.user().id;
@@ -1578,10 +1658,15 @@
focusEl.append(first, second);
}
var textIds = { contour: '#ditem-para1', wrinkles: '#ditem-para2', color: '#tx-tone', smooth: '#tx-smooth', water: '#tx-water', sens: '#tx-sens', acne: '#tx-acne' };
presentation.views.forEach(function (v) { $(textIds[v.key]).textContent = ReportUI.customerText(v); });
presentation.views.forEach(function (v) {
var text = ReportUI.customerText(v), el = $(textIds[v.key]);
el.textContent = text;
el.hidden = !text;
});
// 检测可视化:轮廓=正脸实拍,其余按 VIZ_CAT 取 face_maps 图层或叠加 marks(payload 缺失时各卡保留占位)
renderViz(payload, frontPhoto);
renderWrinkleSides(reportPhotos || [frontPhoto, sidePhoto, null], payload);
// Fourth feedback: capture quality belongs in the protected admin record,
// not in the customer report. Preserve vm.notes/snapshots unchanged.
@@ -1692,7 +1777,7 @@
card.addEventListener('click', function (e) {
var btn = e.target.closest('.dtab');
if (btn) { setDetailTab(card, btn.dataset.tab); return; }
var sideButton = e.target.closest('.contour-side-photo');
var sideButton = e.target.closest('.contour-side-photo, .wrinkle-side-photo');
var img = sideButton ? sideButton.querySelector('img') : e.target.closest('.ditem-img');
if (img && img.src && !img.hidden && img.closest('.dpanel').classList.contains('active')) {
$('#zoom-img').src = img.src;
@@ -1944,7 +2029,7 @@
renderReport(SkinScore.buildViewModel(mockRes, mockSide, {
realAge: +($('#f-age').value || 0) || 28, // 调试档案缺省 28 岁
skinType: SKINS[skinIndex].name
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
}), fx ? fx.photos[0] : null, null, fx ? { result: mockRes, side: mockSide, wrinkle_views: fx.wrinkle_views, face_rectangle: fx.face_rectangle } : null, fx && fx.photos);
} catch (e) { /* 调试入口异常不阻塞正式流程 */ }
}
})();