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:
@@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user