From f3b74b572a7edb514af797890c7fd6cda02d6b18 Mon Sep 17 00:00:00 2001 From: Edom <463882408@qq.com> Date: Sat, 22 Aug 2026 22:56:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=8A=A0=E4=B8=8A=E6=8B=8D=E7=85=A7?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8E=E8=87=AA=E5=AE=9A=E4=B9=89=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E6=B8=90=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- replica/src/character.js | 91 +++- replica/src/eyes.js | 41 +- src/App.vue | 325 ++++++++++++-- src/components/AvatarStage.vue | 5 +- src/components/BotTile.vue | 5 +- src/components/Customizer.vue | 428 +++++++++++++++++- src/components/Hero.vue | 2 +- src/components/ManualCanvas.vue | 37 +- src/components/PhotoFrame.vue | 156 +++++++ src/components/PhotoPanel.vue | 739 +++++++++++++++++++++++++++++++ src/components/TimelineTrack.vue | 8 +- src/i18n/locales/en.ts | 58 ++- src/i18n/locales/zh.ts | 58 ++- src/replica/boot.ts | 2 + src/replica/catalog.ts | 69 +++ src/replica/host.ts | 27 +- src/styles.css | 463 +++++++++++++++++++ src/ui/cadran.ts | 19 + src/ui/capture.ts | 3 +- src/ui/photo.ts | 248 +++++++++++ src/ui/pose/math.ts | 40 +- src/ui/pose/model.ts | 7 +- src/ui/stockage.ts | 2 +- 23 files changed, 2707 insertions(+), 126 deletions(-) create mode 100644 src/components/PhotoFrame.vue create mode 100644 src/components/PhotoPanel.vue create mode 100644 src/ui/cadran.ts create mode 100644 src/ui/photo.ts diff --git a/replica/src/character.js b/replica/src/character.js index 6f4f24f..9c74c93 100644 --- a/replica/src/character.js +++ b/replica/src/character.js @@ -73,6 +73,7 @@ this.sizePx = opts.sizePx || null; this.eyeColor = opts.eyeColor || null; this.inkFlat = opts.inkFlat || null; + this.inkGrad = opts.inkGrad || null; this.geo = opts.geo || g.GROK_GEO; this.playlists = opts.playlist || EYE_PLAYLIST; @@ -421,20 +422,65 @@ setColor(id, scheme) { this.colorId = id; if (scheme) this.scheme = scheme; - if (this.inkFlat) { - this.svg.style.setProperty("--fg", this.inkFlat); - } else if (this.loginWrap) { - this.svg.style.setProperty("--fg", inkFg(id)); + if (this.inkGrad) { + this._paintInkGrad(); } else { - const pal = this.geo.palette[id] || this.geo.palette.black; - this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light); + if (this.inkFlat) { + this.svg.style.setProperty("--fg", this.inkFlat); + } else if (this.loginWrap) { + this.svg.style.setProperty("--fg", inkFg(id)); + } else { + const pal = this.geo.palette[id] || this.geo.palette.black; + this.svg.style.setProperty("--fg", this.scheme === "dark" ? pal.dark : pal.light); + } + this.svg.style.setProperty("--ink", inkCss(id)); + this.body.setAttribute("fill", "var(--fg, #000)"); } - this.svg.style.setProperty("--ink", inkCss(id)); this.svg.style.setProperty("--bg", this.eyeColor || EYE_BG); } + _paintInkGrad() { + const spec = this.inkGrad; + const vb = this.geo.viewBox; + const cx = vb.minX + vb.width / 2; + const cy = vb.minY + vb.height / 2; + if (spec.radial) { + const r = (Math.hypot(vb.width, vb.height) / 2) * 0.72; + this.inkRadialEl.setAttribute("cx", cx); + this.inkRadialEl.setAttribute("cy", vb.minY + vb.height * 0.42); + this.inkRadialEl.setAttribute("r", r); + this.inkRadialStops[0].setAttribute("stop-color", spec.from); + this.inkRadialStops[1].setAttribute("stop-color", spec.to); + this.body.setAttribute("fill", `url(#${this.inkRadialId})`); + this.svg.style.setProperty("--fg", spec.from); + this.svg.style.setProperty("--ink", `radial-gradient(circle at 50% 42%, ${spec.from}, ${spec.to})`); + return; + } + const deg = ((Number(spec.angle) || 0) % 360 + 360) % 360; + const rad = (deg * Math.PI) / 180; + const span = Math.hypot(vb.width, vb.height) / 2; + const dx = Math.sin(rad) * span; + const dy = -Math.cos(rad) * span; + this.inkGradEl.setAttribute("x1", cx - dx); + this.inkGradEl.setAttribute("y1", cy - dy); + this.inkGradEl.setAttribute("x2", cx + dx); + this.inkGradEl.setAttribute("y2", cy + dy); + this.inkStops[0].setAttribute("stop-color", spec.from); + this.inkStops[1].setAttribute("stop-color", spec.to); + this.body.setAttribute("fill", `url(#${this.inkGradId})`); + this.svg.style.setProperty("--fg", spec.from); + this.svg.style.setProperty("--ink", `linear-gradient(${deg}deg, ${spec.from}, ${spec.to})`); + } + setInk(flat) { this.inkFlat = flat || null; + if (this.inkFlat) this.inkGrad = null; + this.setColor(this.colorId); + } + + setInkGrad(grad) { + this.inkGrad = grad ? { ...grad } : null; + if (this.inkGrad) this.inkFlat = null; this.setColor(this.colorId); } @@ -738,6 +784,28 @@ this.clipPath = document.createElementNS(ns, "path"); clip.appendChild(this.clipPath); defs.appendChild(clip); + this.inkGradId = `grok-ink-${Math.random().toString(36).slice(2, 8)}`; + this.inkGradEl = document.createElementNS(ns, "linearGradient"); + this.inkGradEl.setAttribute("id", this.inkGradId); + this.inkGradEl.setAttribute("gradientUnits", "userSpaceOnUse"); + this.inkStops = [0, 1].map((offset) => { + const s = document.createElementNS(ns, "stop"); + s.setAttribute("offset", String(offset)); + this.inkGradEl.appendChild(s); + return s; + }); + defs.appendChild(this.inkGradEl); + this.inkRadialId = `grok-ink-r-${Math.random().toString(36).slice(2, 8)}`; + this.inkRadialEl = document.createElementNS(ns, "radialGradient"); + this.inkRadialEl.setAttribute("id", this.inkRadialId); + this.inkRadialEl.setAttribute("gradientUnits", "userSpaceOnUse"); + this.inkRadialStops = [0, 1].map((offset) => { + const s = document.createElementNS(ns, "stop"); + s.setAttribute("offset", String(offset)); + this.inkRadialEl.appendChild(s); + return s; + }); + defs.appendChild(this.inkRadialEl); this.svg.appendChild(defs); this.group = document.createElementNS(ns, "g"); @@ -1318,12 +1386,21 @@ manualMix: 0, }); if (holdMix > 0.002) { + // Eye-sphere rim = visible head half-width in overlay units, so the + // fold/slice lands on the silhouette like the original lab. + let rim = 95; + if (restRing) { + let hw = 0; + for (const p of restRing) hw = Math.max(hw, Math.abs(p[0] - R)); + rim = (hw * 300) / (R * 3.16); + } EY.blendCustomEyes({ eyeEls: this.eyeEls, pose: this._poseNow(), eyes: this.eyeTune, svg: this.svg, mix: holdMix, + rim, }); } diff --git a/replica/src/eyes.js b/replica/src/eyes.js index d5eee95..e849534 100644 --- a/replica/src/eyes.js +++ b/replica/src/eyes.js @@ -301,19 +301,25 @@ return pts; } - function projectEye(pose, home, faceX, faceY, lx, ly, angle) { + function projectEye(pose, home, faceX, faceY, lx, ly, angle, rim) { const a = (angle || 0) * Math.PI / 180; const c = Math.cos(a); const s = Math.sin(a); const x = faceX + lx * c - ly * s; const y = faceY + lx * s + ly * c; - const lon = x / FACE_R; - const lat = y / FACE_R; - const cl = Math.cos(lat); - const rotated = quatRotate(screenOf(pose, home), [cl * Math.sin(lon), Math.sin(lat), cl * Math.cos(lon)]); - const depth = rotated[2] * FACE_R; + // Face coords are authored against FACE_R=120 (original lab units) but the + // physical head rides `rim` overlay units so the eye sphere's silhouette + // lands on the visible head edge. Past the rim points stay raw on the z=0 + // plane (ellipsoidFrontSample semantics): the eye rides over the edge and + // gets sliced by the head clip instead of folding back inward. + const headR = rim || 95; + const dx = x / FACE_R; + const dy = y / FACE_R; + const dz = Math.sqrt(Math.max(0, 1 - dx * dx - dy * dy)); + const rotated = quatRotate(screenOf(pose, home), [dx, dy, dz]); + const depth = rotated[2] * headR; const sc = FOCAL / Math.max(FOCAL - depth, 0.0001); - return [rotated[0] * FACE_R * sc, rotated[1] * FACE_R * sc, rotated[2]]; + return [rotated[0] * headR * sc, rotated[1] * headR * sc, rotated[2]]; } function overlayToSvg(svg, x, y) { @@ -369,7 +375,7 @@ return pts; } - function customEyePolys({ pose, eyes, svg, n = 48, home = EYE_HOME }) { + function customEyePolys({ pose, eyes, svg, n = 48, home = EYE_HOME, rim }) { if (!svg || !pose) return null; const src = eyes || DEFAULT_EYES; const sides = ["left", "right"]; @@ -380,18 +386,23 @@ const local = roundedRect(Math.max(tune.width, 1), Math.max(tune.height, 1)); let z = 0; const projected = local.map(([lx, ly]) => { - const p = projectEye(pose, home, faceX, faceY, lx, ly, tune.angle); + const p = projectEye(pose, home, faceX, faceY, lx, ly, tune.angle, rim); z += p[2]; return overlayToSvg(svg, p[0], p[1]); }); - return { pts: resampleRing(projected, n), visible: z > 0 }; + return { pts: resampleRing(projected, n), visible: z > 0, zAvg: z / Math.max(local.length, 1) }; }); } - function blendCustomEyes({ eyeEls, pose, eyes, svg, mix, n = 48 }) { + // Visibility hysteresis: drag jitter around the silhouette boundary would + // otherwise pop the eye in and out every few frames. + const VIS_HIDE = -0.006; + const VIS_SHOW = 0.004; + + function blendCustomEyes({ eyeEls, pose, eyes, svg, mix, n = 48, rim }) { const k = clamp(mix, 0, 1); if (k <= 0.002 || !eyeEls) return; - const custom = customEyePolys({ pose, eyes, svg, n }); + const custom = customEyePolys({ pose, eyes, svg, n, rim }); if (!custom) return; const gate = 0.22; const shapeK = k >= gate ? 1 : Dke(k / gate); @@ -399,9 +410,13 @@ const el = eyeEls[i]; const expr = sampleEl(el, n); if (!el || !expr || !custom[i]) continue; + let shown = el.__eyeShown != null ? el.__eyeShown : custom[i].visible; + if (shown && custom[i].zAvg < VIS_HIDE) shown = false; + if (!shown && custom[i].zAvg > VIS_SHOW) shown = true; + el.__eyeShown = shown; el.setAttribute("d", polyPath(lerpPoly(custom[i].pts, expr, 1 - shapeK))); el.removeAttribute("transform"); - if (shapeK > 0.55) el.style.display = custom[i].visible ? "" : "none"; + if (shapeK > 0.55) el.style.display = shown ? "" : "none"; } } diff --git a/src/App.vue b/src/App.vue index 9626b4e..669e923 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,6 +7,8 @@ import CycleDialog from '@/components/CycleDialog.vue' import ExportBar from '@/components/ExportBar.vue' import GifDialog from '@/components/GifDialog.vue' import AvatarStage from '@/components/AvatarStage.vue' +import PhotoFrame from '@/components/PhotoFrame.vue' +import PhotoPanel from '@/components/PhotoPanel.vue' import Settings from '@/components/Settings.vue' import SideRail, { type ViewId } from '@/components/SideRail.vue' import Timeline from '@/components/Timeline.vue' @@ -47,11 +49,19 @@ import { cyclePas, nomFichier, tailleAction, + viewBoxExport, type ActionId, type EtatExport, type FondGif, type FormatCycle } from '@/ui/export' +import { + ANGLE_DEFAUT, + COMPOSITION_DEFAUT, + svgPhoto, + type CompositionPhoto, + type FondPhoto +} from '@/ui/photo' import { parseManual, persistable, type ManualState } from '@/ui/pose/model' import { ecris, lis } from '@/ui/stockage' @@ -61,6 +71,7 @@ const ready = ref(false) const savedShape = lis('forme') const shape = ref(savedShape && estForme(savedShape) ? savedShape : DEFAULT_SHAPE) const color = ref(lis('couleur') || DEFAULT_COLOR) +const oeil = ref(lis('oeil') || '') const expression = ref(lis('expression') || DEFAULT_EXPRESSION) const intro = ref(true) const hero = ref<{ @@ -85,6 +96,20 @@ const hero = ref<{ } | null>(null) const manual = ref(parseManual(lis('manuel'))) +/* ---- mode photo : cadrage, fond et capture de l'avatar fige ---- */ +const photo = ref(false) +const photoOutil = ref<'cadre' | 'pose'>('cadre') +const photoComposition = ref({ ...COMPOSITION_DEFAUT, cornerRadius: 18 }) +const photoFond = ref('transparent') +const photoCouleurDe = ref('#F5F7FC') +const photoCouleurA = ref('#C9D5FF') +const photoAngle = ref(ANGLE_DEFAUT) +const photoFormat = ref<'png' | 'svg'>('png') +const photoTaille = ref(1024) +const photoFlash = ref(0) +const photoAvantJoue = ref(true) +let minuteurPhoto: ReturnType | undefined + const restored = parseCycles(lis('cycles')) const cycles = ref(restored.length ? restored : [defaultCycle()]) const activeId = ref( @@ -103,18 +128,23 @@ const state = ref(intro.value ? 'idle' : (cycle.value.blocks[block.value]?.state const gauche = computed(() => view.value === 'reglages' && !preview.value) const droite = computed(() => view.value !== 'reglages' && !preview.value && !intro.value) +const plein = computed(() => preview.value || photo.value) const follow = computed(() => view.value === 'reglages' && !preview.value) const nue = computed(() => intro.value && !preview.value) const playedState = computed(() => { + if (photo.value) return expression.value if (view.value === 'animations' || preview.value) return state.value return expression.value }) const avatarSize = computed(() => { + if (photo.value) return 460 if (preview.value) return 560 if (view.value === 'personnaliser') return 580 return 440 }) const avatarMax = computed(() => { + if (photo.value) + return 'max-w-[min(460px,68vw,calc(100dvh_-_14rem))] max-lg:max-w-[calc(54dvh_-_12.5rem)]' if (preview.value) return 'max-w-[min(560px,calc(100dvh_-_6rem))]' if (view.value === 'personnaliser') return 'max-w-[min(600px,calc(100dvh_-_var(--timeline)_-_7rem))]' @@ -126,8 +156,17 @@ watch(shape, (id) => { if (manual.value.selected) manual.value = { ...manual.value, selected: null } }) watch(color, (id) => ecris('couleur', id)) +watch(oeil, (v) => ecris('oeil', v)) watch(expression, (id) => { ecris('expression', id) + if (photo.value) { + // en photo, l'avatar est fige : on libere le temps de la transition + // spring vers la nouvelle expression, puis on regele. + hero.value?.setPaused(false) + clearTimeout(minuteurPhoto) + minuteurPhoto = setTimeout(() => hero.value?.freezeNow({ settle: true }), 700) + return + } if (view.value !== 'animations') state.value = id if (view.value !== 'personnaliser') return poseIdx.value = 0 @@ -203,7 +242,13 @@ watch( if (view.value !== 'personnaliser') return if (on) { posePlay.value = false + // en photo, l'entree a gele le moteur : le degeler pour que le + // ressort manualMix reparte et laisse apparaitre les gizmos. + if (photo.value) hero.value?.setPaused(false) pausePose() + } else { + posePlay.value = true + releasePoseFreeze() } } ) @@ -247,10 +292,95 @@ function onStopPose() { if (view.value === 'personnaliser') pausePose(true) } +function ouvrePhoto() { + if (!hero.value || intro.value || preview.value) return + // fige l'instant present : l'expression affichee devient la pose photo. + expression.value = playedState.value + photoOutil.value = 'cadre' + photoAvantJoue.value = posePlay.value + posePlay.value = false + poseStopped.value = false + pausePose(true) + photo.value = true +} + +function fermePhoto() { + photo.value = false + clearTimeout(minuteurPhoto) + photoFlash.value = 0 + hero.value?.setPaused(false) + posePlay.value = photoAvantJoue.value && !manual.value.on + void nextTick(applyPosePlayback) +} + +function recadrePhoto() { + photoComposition.value = { + ...COMPOSITION_DEFAUT, + cornerRadius: photoComposition.value.cornerRadius + } +} + +function choisitOutil(outil: 'cadre' | 'pose') { + photoOutil.value = outil + if (outil === 'pose' && !manual.value.on) { + manual.value = { ...manual.value, on: true, selected: manual.value.selected ?? 'body' } + } +} + +/** + * Choisir une expression quitte le reglage manuel (la pose revient a + * l'expression) puis enchaine la transition — meme si l'expression cliquee + * est celle d'origine, la grille n'affichant aucune selection en manuel. + */ +function choisitExpression(id: string) { + if (manual.value.on) manual.value = { ...manual.value, on: false, selected: null } + if (id === expression.value) { + hero.value?.setPaused(false) + clearTimeout(minuteurPhoto) + minuteurPhoto = setTimeout(() => hero.value?.freezeNow({ settle: true }), 700) + return + } + expression.value = id +} + +async function prendPhoto() { + const svg = hero.value?.svg() + if (!svg || !photo.value || etatExport.value === 'occupe') return + clearTimeout(minuteurPhoto) + hero.value?.freezeNow({ settle: true }) + photoFlash.value++ + clearTimeout(confirmation) + etatExport.value = 'occupe' + try { + // le viewBox vivant du moment : c'est lui que le cadre affiche. + const vue = svg.getAttribute('viewBox') || viewBoxExport() + const markup = svgAutonome(svg, 300, vue) + const complet = svgPhoto(markup, { + fond: photoFond.value, + couleurDe: photoCouleurDe.value, + couleurA: photoCouleurA.value, + angle: photoAngle.value, + taille: photoTaille.value, + composition: photoComposition.value + }) + const nom = nomFichier(shape.value, expression.value, color.value, photoFormat.value, 'photo') + const fichier = + photoFormat.value === 'svg' + ? new Blob([complet], { type: 'image/svg+xml' }) + : await versPng(complet, photoTaille.value) + telecharge(fichier, nom) + etatExport.value = 'exporte' + } catch { + etatExport.value = 'erreur' + } + confirmation = setTimeout(() => (etatExport.value = 'pret'), CONFIRMATION_MS) +} + const gardeAnim = ref(false) watch(view, (now, prev) => { intro.value = false + if (photo.value) fermePhoto() if (prev === 'personnaliser' && now !== 'personnaliser') releasePoseFreeze() if (now === 'animations') { gardeAnim.value = true @@ -313,9 +443,12 @@ function tick(ms: number) { } function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') preview.value = false + if (e.key === 'Escape') { + if (photo.value) fermePhoto() + else preview.value = false + } if (e.code !== 'Space') return - if (view.value !== 'animations' && !preview.value) return + if (view.value !== 'animations' && !preview.value && !photo.value) return if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return const node = e.target if ( @@ -360,7 +493,12 @@ async function exporteCycle() { const images = cycleImages(totalDuration(blocs), format) const pas = cyclePas(format) const taille = CYCLE_TAILLE[format] - const reglages = { shape: shape.value, color: color.value, expression: expression.value } + const reglages = { + shape: shape.value, + color: color.value, + oeil: oeil.value || undefined, + expression: expression.value + } const suit = (fait: number, total: number) => (avancementCycle.value = fait / total) avancementCycle.value = 0 try { @@ -423,11 +561,21 @@ async function exporte(id: ActionId, confirme = false) { nomFichier(shape.value, expression.value, color.value, action.extension, action.suffixe) try { if (action.mode === 'anime') { - const reglages = { shape: shape.value, color: color.value, expression: expression.value } + const reglages = { + shape: shape.value, + color: color.value, + oeil: oeil.value || undefined, + expression: expression.value + } telecharge(await versSvgAnime(reglages, cote, ANIM_IMAGES, ANIM_PAS), nom()) etatExport.value = 'exporte' } else if (action.mode === 'gif') { - const reglages = { shape: shape.value, color: color.value, expression: expression.value } + const reglages = { + shape: shape.value, + color: color.value, + oeil: oeil.value || undefined, + expression: expression.value + } telecharge( await versGifAnime(reglages, action.taille, GIF_IMAGES, GIF_PAS, couleurDeFond(fondGif.value)), nom() @@ -482,6 +630,7 @@ onUnmounted(() => { clearTimeout(pending) clearTimeout(minuteurBarre) clearTimeout(confirmation) + clearTimeout(minuteurPhoto) annuleCycle() }) @@ -491,7 +640,17 @@ onUnmounted(() => { + + + +
+
+ + +
+ +
+ +
+
{ /> {