feat(report): add annotated wrinkle views and restore capture countdown
This commit is contained in:
+103
-18
@@ -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) { /* 调试入口异常不阻塞正式流程 */ }
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user