optimize: community polish — crossfade, position lock, effort sync

- 双图层交叉淡化人像过渡:备用层加载完成才淡入,无白闪;拖动 120ms 快速过渡、落定 340ms 慢速过渡,prefers-reduced-motion 直切
- 滑块位置固定:原生模型座位按钮级 180px 固定足迹(选择器限定 button,避免误伤 label/effort 子元素)+ 滑块 flex 固定尺寸 + 交互期 rAF 漂移补偿
- 原生推理等级与滑块实时同步:拖动预览乐观回写共享 directory store,松手提交、失败/Esc/取消自动回滚
- 滑动更丝滑:移除图片序列模式下对隐藏 video 的每帧 seek,24 张锚点预加载 + decoding=async,交叉淡化走合成器动画
- 滑块自定义光标:liang-cursor.cur(热点居中 16,16),加载失败回退 ew-resize
- 版本升至 0.2.0,随包新增 CHANGELOG.md 优化说明
This commit is contained in:
jiaererw
2026-08-18 16:32:25 +08:00
parent 08f60e8ef3
commit 9c06acf15b
9 changed files with 1164 additions and 714 deletions

View File

@@ -54,6 +54,8 @@ const PORTRAIT_ANCHORS = [
{ level: 30, file: "stage-30.png" },
] as const;
const ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
interface SkinSettings {
enabled: boolean;
bindEffort: boolean;
@@ -141,13 +143,16 @@ class SkinPresenter {
private readonly root: HTMLDivElement;
private readonly video: HTMLVideoElement;
private readonly poster: HTMLImageElement;
private readonly portrait: HTMLImageElement;
private readonly frames: HTMLImageElement[];
private readonly preloads: HTMLImageElement[];
private enabled = true;
// Default to the max frame so the first paint after load is the dark shell;
// starting at 0 flashed the light palette before the directory resolved.
private frame = PREVIEW_MAX_FRAME;
private pendingFrame = PREVIEW_MAX_FRAME;
private pendingMode: "drag" | "settle" = "settle";
private activeFrame = 0;
private pendingSource: string | null = null;
private raf = 0;
private disposed = false;
private unsubscribe: () => void;
@@ -159,6 +164,7 @@ class SkinPresenter {
this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID;
this.root.dataset.media = "sequence";
this.root.dataset.crossfade = "slow";
this.root.setAttribute("aria-hidden", "true");
this.video = document.createElement("video");
@@ -167,7 +173,7 @@ class SkinPresenter {
this.video.preload = "auto";
// A video poster stays painted while a paused video is scrubbed in some
// browsers, which would make every effort show the same portrait. Keep the
// separate image element as the true load-error fallback instead.
// separate image elements as the true load-error fallback instead.
const webm = document.createElement("source");
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
webm.type = "video/webm";
@@ -181,19 +187,29 @@ class SkinPresenter {
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
this.poster.alt = "";
this.portrait = document.createElement("img");
this.portrait.className = "liang-skin-sequence-frame";
this.portrait.alt = "";
this.portrait.draggable = false;
this.portrait.addEventListener("error", this.handleSequenceError);
// Two stacked crossfade layers: the standby layer loads the next portrait
// while the active one stays painted, then an opacity/scale transition
// hands over — no hard src swap, no white flash.
this.frames = [];
for (let index = 0; index < 2; index += 1) {
const image = document.createElement("img");
image.className = "liang-skin-sequence-frame";
image.alt = "";
image.draggable = false;
image.decoding = "async";
image.addEventListener("error", this.handleSequenceError);
image.addEventListener("load", () => this.handleFrameLoaded(image));
this.frames.push(image);
}
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
const image = new Image();
image.decoding = "async";
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
return image;
});
this.root.append(this.video, this.portrait, this.poster);
this.root.append(this.video, this.frames[0], this.frames[1], this.poster);
document.body.prepend(this.root);
this.video.addEventListener("loadedmetadata", this.handleMetadata);
@@ -246,9 +262,10 @@ class SkinPresenter {
}
}
setFrame(frame: number) {
setFrame(frame: number, mode: "drag" | "settle" = "drag") {
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
this.frame = this.pendingFrame;
this.pendingMode = mode;
if (!this.enabled) return;
if (this.raf !== 0) return;
this.raf = requestAnimationFrame(() => {
@@ -278,8 +295,11 @@ class SkinPresenter {
body.style.setProperty("--liang-accent-hover", palette.accentHover);
body.style.setProperty("--liang-hover", palette.hover);
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
this.root.dataset.crossfade = this.pendingMode === "settle" ? "slow" : "fast";
this.updatePortrait(palette.level);
this.seek(this.frame);
// Seeking a hidden <video> is pure cost (and visibly janky) during drags
// in the default image-sequence mode; only the video fallback needs it.
if (this.root.dataset.media === "video") this.seek(this.frame);
}
syncNativeTheme(theme: NativeThemeId = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
@@ -288,15 +308,47 @@ class SkinPresenter {
}
private updatePortrait(level: number) {
if (this.root.dataset.media !== "sequence") return;
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
level,
PORTRAIT_ANCHORS.map((anchor) => anchor.level),
ANCHOR_LEVELS,
);
const lower = PORTRAIT_ANCHORS[lowerIndex];
const upper = PORTRAIT_ANCHORS[upperIndex];
const selected = mix >= 0.5 ? upper : lower;
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
const active = this.frames[this.activeFrame];
if (active.getAttribute("src") === source) {
// Already showing this anchor; drop any stale queued handover.
this.pendingSource = null;
return;
}
if (this.pendingSource === source) return;
const standbyIndex = 1 - this.activeFrame;
const standby = this.frames[standbyIndex];
this.pendingSource = source;
if (standby.getAttribute("src") !== source) standby.src = source;
// Cache hit (preloaded): hand over immediately — the CSS transition still
// eases the swap, so a fast drag stays glued to the pointer.
if (standby.complete && standby.naturalWidth > 0) {
this.activate(standbyIndex);
}
}
private readonly handleFrameLoaded = (image: HTMLImageElement) => {
if (
this.pendingSource === null
|| image.getAttribute("src") !== this.pendingSource
) return;
this.activate(this.frames.indexOf(image));
};
private activate(index: number) {
if (index < 0 || index === this.activeFrame) return;
this.frames[this.activeFrame].classList.remove("is-active");
this.activeFrame = index;
this.frames[index].classList.add("is-active");
this.pendingSource = null;
}
private seek(frame: number) {
@@ -324,7 +376,10 @@ class SkinPresenter {
this.video.pause();
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
this.video.removeEventListener("error", this.handleVideoError);
this.portrait.removeEventListener("error", this.handleSequenceError);
for (const image of this.frames) {
image.removeEventListener("error", this.handleSequenceError);
image.src = "";
}
this.poster.removeEventListener("error", this.handlePosterError);
for (const image of this.preloads) image.src = "";
this.root.remove();
@@ -376,6 +431,13 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
const [interacting, setInteracting] = useState(false);
const dragging = useRef(false);
const dragStartFrame = useRef(frame);
const rootRef = useRef<HTMLDivElement>(null);
const interactionArmed = useRef(false);
const dragStartCommittedId = useRef<string | undefined>(undefined);
const dragStartCurrent = useRef<ModelSelection | null>(null);
const previewSyncedId = useRef<string | undefined>(undefined);
const lockRaf = useRef(0);
const lockBaseLeft = useRef<number | null>(null);
const enabled = skin.enabled;
useEffect(() => {
@@ -385,9 +447,44 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
useEffect(() => {
if (!bindEffort || dragging.current || pending) return;
setFrame(committedFrame);
presenter.setFrame(committedFrame);
presenter.setFrame(committedFrame, "settle");
}, [bindEffort, committedFrame, pending, presenter]);
// While the user interacts (or a commit is in flight), the native model
// seat to the right may change its label width and reflow this flex row.
// Pin the control's on-screen position each frame and compensate any drift
// with a transform, then glide back smoothly when the interaction ends.
const locked = interacting || pending;
useEffect(() => {
if (!locked) return;
const root = rootRef.current;
if (root === null) return;
root.style.transition = "";
root.style.willChange = "transform";
lockBaseLeft.current = root.getBoundingClientRect().left;
const step = () => {
lockRaf.current = requestAnimationFrame(step);
const base = lockBaseLeft.current;
if (base === null) return;
const drift = base - root.getBoundingClientRect().left;
if (Math.abs(drift) > 0.5) {
root.style.transform = `translateX(${drift.toFixed(2)}px)`;
}
};
lockRaf.current = requestAnimationFrame(step);
return () => {
cancelAnimationFrame(lockRaf.current);
lockRaf.current = 0;
root.style.transition = "transform 200ms ease-out";
root.style.transform = "";
window.setTimeout(() => {
root.style.transition = "";
root.style.willChange = "";
}, 220);
lockBaseLeft.current = null;
};
}, [locked]);
if (!enabled) return null;
if (bindEffort && (reasoning === null || efforts.length < 2)) return null;
@@ -396,21 +493,62 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
const progressRatio = frame / PREVIEW_MAX_FRAME;
const tooltipLabel = indicatorLabel(frame, bindEffort ? efforts : []);
const beginInteraction = () => {
if (interactionArmed.current) return;
interactionArmed.current = true;
dragging.current = true;
dragStartFrame.current = frame;
const snapshot = directory.getSnapshot();
dragStartCurrent.current = snapshot.current;
const snapshotReasoning = modelReasoning(snapshot);
dragStartCommittedId.current = snapshotReasoning?.selection.reasoningEffort
?? snapshotReasoning?.defaultEffort;
previewSyncedId.current = undefined;
};
// Roll the shared directory store back to the value captured before this
// interaction (undoes any optimistic preview echo).
const restorePreviewSync = () => {
const original = dragStartCurrent.current;
if (original === null || previewSyncedId.current === undefined) return;
directory.store.update((draft) => {
draft.current = original;
});
previewSyncedId.current = undefined;
};
const cancelInteraction = () => {
setInteracting(false);
interactionArmed.current = false;
dragging.current = false;
setFrame(dragStartFrame.current);
presenter.setFrame(dragStartFrame.current, "settle");
restorePreviewSync();
};
const commit = async (rawFrame: number) => {
interactionArmed.current = false;
dragging.current = false;
if (!bindEffort) {
dragging.current = false;
setFrame(rawFrame);
presenter.setFrame(rawFrame);
presenter.setFrame(rawFrame, "settle");
return;
}
const targetIndex = nearestEffortIndex(rawFrame, efforts);
const target = efforts[targetIndex];
if (target === undefined) return;
if (target === undefined) {
restorePreviewSync();
return;
}
const targetFrame = frameForEffort(targetIndex, efforts.length);
dragging.current = false;
setFrame(targetFrame);
presenter.setFrame(targetFrame);
if (targetIndex === committedIndex || pending) return;
presenter.setFrame(targetFrame, "settle");
if (target.id === dragStartCommittedId.current) {
// No real change: just undo any optimistic preview echo.
restorePreviewSync();
return;
}
if (pending) return;
setPending(true);
setFailed(false);
const accepted = await select({
@@ -421,13 +559,25 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
setPending(false);
if (!accepted) {
setFailed(true);
setFrame(committedFrame);
presenter.setFrame(committedFrame);
restorePreviewSync();
const rollbackIndex = selectedEffortIndex(
efforts,
dragStartCommittedId.current,
undefined,
);
const rollbackFrame = rollbackIndex < 0
? PREVIEW_MAX_FRAME
: frameForEffort(rollbackIndex, efforts.length);
setFrame(rollbackFrame);
presenter.setFrame(rollbackFrame, "settle");
} else {
previewSyncedId.current = undefined;
}
};
return (
<div
ref={rootRef}
className="liang-effort-control"
data-plugin={PACKAGE_ID}
data-state={failed ? "error" : pending ? "pending" : "ready"}
@@ -461,25 +611,38 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
aria-label={bindEffort ? "思考等级" : "皮肤进度"}
aria-valuetext={bindEffort ? previewEffort?.name ?? "" : tooltipLabel}
onPointerDown={() => {
dragging.current = true;
dragStartFrame.current = frame;
beginInteraction();
setInteracting(true);
}}
onInput={(event) => {
dragging.current = true;
const next = Number(event.currentTarget.value);
setFrame(next);
presenter.setFrame(next);
presenter.setFrame(next, "drag");
if (!bindEffort || reasoning === null) return;
// Optimistically echo the nearest preview effort into the shared
// directory store so the native model seat updates in lockstep
// while dragging; commit/rollback settles the final value.
const index = nearestEffortIndex(next, efforts);
const effort = efforts[index];
if (effort === undefined || effort.id === previewSyncedId.current) return;
previewSyncedId.current = effort.id;
directory.store.update((draft) => {
const current = draft.current;
if (
current === null
|| current.provider !== reasoning.selection.provider
|| current.model !== reasoning.selection.model
) return;
current.reasoningEffort = effort.id;
});
}}
onPointerUp={(event) => {
setInteracting(false);
void commit(Number(event.currentTarget.value));
}}
onPointerCancel={() => {
setInteracting(false);
dragging.current = false;
setFrame(dragStartFrame.current);
presenter.setFrame(dragStartFrame.current);
cancelInteraction();
}}
onKeyUp={(event) => {
setInteracting(false);
@@ -491,11 +654,9 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
}}
onKeyDown={(event) => {
setInteracting(true);
beginInteraction();
if (event.key === "Escape" && !pending) {
setInteracting(false);
dragging.current = false;
setFrame(dragStartFrame.current);
presenter.setFrame(dragStartFrame.current);
cancelInteraction();
}
}}
/>