@@ -28,7 +28,6 @@ dsh plugin --profile web add 'github:kingOfSoySauce/dsh-liang-skin'
|
|||||||
|
|
||||||
- #### 方法三, 或者[命令安装](#cli-install);运行前请关闭其他皮肤插件,避免冲突
|
- #### 方法三, 或者[命令安装](#cli-install);运行前请关闭其他皮肤插件,避免冲突
|
||||||
|
|
||||||
|
|
||||||
## 更多 DSH 皮肤
|
## 更多 DSH 皮肤
|
||||||
|
|
||||||
更多社区皮肤,点击[在线浏览皮肤市场](https://kingofsoysauce.github.io/dsh-skin-market/)
|
更多社区皮肤,点击[在线浏览皮肤市场](https://kingofsoysauce.github.io/dsh-skin-market/)
|
||||||
@@ -64,13 +63,13 @@ dsh plugin --profile web add 'github:kingOfSoySauce/dsh-liang-skin'
|
|||||||
- `滑动变祖`:显示滑块并启用人物、背景和自适应配色。
|
- `滑动变祖`:显示滑块并启用人物、背景和自适应配色。
|
||||||
- `原生`:移除背景层、皮肤变量和滑块,恢复 Harness 原生界面。
|
- `原生`:移除背景层、皮肤变量和滑块,恢复 Harness 原生界面。
|
||||||
|
|
||||||
“原生”只对当前 Liang client 激活周期生效;从皮肤市场切换到其他皮肤后再切回,梁祖皮肤会重新显示。该选择不会改动模型配置。
|
选择保存在当前浏览器本地,不会改动模型配置。
|
||||||
|
|
||||||
<a id="cli-install"></a>
|
<a id="cli-install"></a>
|
||||||
|
|
||||||
## 命令安装
|
## 命令安装
|
||||||
|
|
||||||
需要先安装 DeepSeek Harness CLI;当前版本已在 `0.1.0-rc.6` 上验证。安装本身可以在 DSH 运行时执行(只改动磁盘配置),重启后生效。三种方式任选其一:
|
需要先安装 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness);当前版本已在 `0.1.0-rc.6` 上验证。安装本身可以在 DSH 运行时执行(只改动磁盘配置),重启后生效。三种方式任选其一:
|
||||||
|
|
||||||
> 安装前请确保已关闭其他皮肤插件,避免冲突。
|
> 安装前请确保已关闭其他皮肤插件,避免冲突。
|
||||||
|
|
||||||
@@ -89,6 +88,7 @@ dsh --profile web --dump-config | grep -B1 -A2 liang-intensity
|
|||||||
dsh plugin --profile web add ./dsh-client-liang-intensity-skin-0.1.4.tgz
|
dsh plugin --profile web add ./dsh-client-liang-intensity-skin-0.1.4.tgz
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
适合不方便走 git 的环境;相对路径按你运行命令的目录解析。
|
适合不方便走 git 的环境;相对路径按你运行命令的目录解析。
|
||||||
|
|
||||||
### 方式三:克隆后从本地路径安装(开发迭代)
|
### 方式三:克隆后从本地路径安装(开发迭代)
|
||||||
@@ -99,6 +99,7 @@ cd dsh-liang-skin
|
|||||||
dsh plugin --profile web add .
|
dsh plugin --profile web add .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
`dsh plugin` 会把相对路径锚定到**你运行命令的目录**(而不是 profile 目录),所以在克隆目录里执行 `add .` 装的是指向克隆目录的 link 依赖:改完源码运行 `npm run build`,重启 DSH 即生效,无需重新安装。
|
`dsh plugin` 会把相对路径锚定到**你运行命令的目录**(而不是 profile 目录),所以在克隆目录里执行 `add .` 装的是指向克隆目录的 link 依赖:改完源码运行 `npm run build`,重启 DSH 即生效,无需重新安装。
|
||||||
|
|
||||||
### 验证与重启
|
### 验证与重启
|
||||||
@@ -139,7 +140,7 @@ npm run build
|
|||||||
|
|
||||||
## 灵感与素材来源
|
## 灵感与素材来源
|
||||||
|
|
||||||
本插件的“滑动变祖”视觉概念、人物图片与插帧视频素材源自
|
本插件的“滑动变祖”视觉概念与人物图片素材源自
|
||||||
[Lichtspektrum/liang-intensity-calibrator](https://github.com/Lichtspektrum/liang-intensity-calibrator)。插件在原项目 0–30 强度轴的基础上,将视觉变化接入 DeepSeek Harness 的推理等级选择。
|
[Lichtspektrum/liang-intensity-calibrator](https://github.com/Lichtspektrum/liang-intensity-calibrator)。插件在原项目 0–30 强度轴的基础上,将视觉变化接入 DeepSeek Harness 的推理等级选择。
|
||||||
|
|
||||||
运行时素材已包含在插件中,安装后不需要额外下载。当前接入 24 张经过审核的人像锚点,滑动时直接切换最近锚点,不做图片交叉渐变。
|
运行时素材已包含在插件中,安装后不需要额外下载。当前接入 24 张经过审核的人像锚点,滑动时直接切换最近锚点,不做图片交叉渐变。
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
# 滑动变祖 · 人像与视频生成交接
|
# 滑动变祖 · 人像生成交接
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
@@ -6,18 +6,17 @@
|
|||||||
- 已生成:**24 / 24**
|
- 已生成:**24 / 24**
|
||||||
- 分辨率与色彩:**1024 × 1024,RGB**
|
- 分辨率与色彩:**1024 × 1024,RGB**
|
||||||
- 生成图目录:`packages/dsh-client-liang-intensity-skin/assets/portrait-source-v2/`
|
- 生成图目录:`packages/dsh-client-liang-intensity-skin/assets/portrait-source-v2/`
|
||||||
- 原视频关键帧目录:`packages/dsh-client-liang-intensity-skin/assets/portrait-source-v2/references/`
|
- 阶段参考图目录:`packages/dsh-client-liang-intensity-skin/assets/portrait-source-v2/references/`
|
||||||
- 原视频:`packages/dsh-client-liang-intensity-skin/assets/liang-evolution.webm`
|
|
||||||
|
|
||||||
`stage-low.png`、`stage-mid.png`、`stage-max.png` 是早期别名。后续生图和生视频只使用表格里的 24 个规范文件名。
|
`stage-low.png`、`stage-mid.png`、`stage-max.png` 是早期别名。后续只使用表格里的 24 个规范文件名。
|
||||||
|
|
||||||
## 公共提示词
|
## 公共提示词
|
||||||
|
|
||||||
下面这段作为每张图的公共提示词。把大括号变量替换为表格中对应内容;阶段服装、表情和装饰必须以该阶段原视频关键帧为准。
|
下面这段作为每张图的公共提示词。把大括号变量替换为表格中对应内容;阶段服装、表情和装饰必须以该阶段参考图为准。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Use case: identity-preserve
|
Use case: identity-preserve
|
||||||
Asset type: square key portrait for a continuous image-to-video character evolution sequence
|
Asset type: square key portrait for a continuous portrait evolution sequence
|
||||||
|
|
||||||
Primary request:
|
Primary request:
|
||||||
Using {REFERENCE_FRAME} as the authoritative stage reference, create the level {LEVEL} key portrait of the same Chinese adult man. Preserve his identity exactly: facial structure, glasses, hairstyle, skin tone, age, eye spacing, nose, mouth and overall proportions. Preserve the outfit, expression, posture and stage-specific accessories shown in the authoritative reference. Do not borrow clothing or accessories from a later stage.
|
Using {REFERENCE_FRAME} as the authoritative stage reference, create the level {LEVEL} key portrait of the same Chinese adult man. Preserve his identity exactly: facial structure, glasses, hairstyle, skin tone, age, eye spacing, nose, mouth and overall proportions. Preserve the outfit, expression, posture and stage-specific accessories shown in the authoritative reference. Do not borrow clothing or accessories from a later stage.
|
||||||
@@ -65,34 +64,3 @@ No close-up crop. No cropped head, shoulders, arms or torso. No side pose. No ex
|
|||||||
| 24 | 30 | 8.000s | 已生成 | `stage-30.png` | `references/stage-30-8.000s.png` | 前一张 `level-29.png` |
|
| 24 | 30 | 8.000s | 已生成 | `stage-30.png` | `references/stage-30-8.000s.png` | 前一张 `level-29.png` |
|
||||||
|
|
||||||
所有表格路径均相对于本文件所在目录。
|
所有表格路径均相对于本文件所在目录。
|
||||||
|
|
||||||
## 生视频输入顺序
|
|
||||||
|
|
||||||
```text
|
|
||||||
stage-00.png
|
|
||||||
level-01.png
|
|
||||||
level-03.png
|
|
||||||
level-04.png
|
|
||||||
stage-06.png
|
|
||||||
level-07.png
|
|
||||||
level-09.png
|
|
||||||
level-10.png
|
|
||||||
stage-12.png
|
|
||||||
level-13.png
|
|
||||||
level-14.png
|
|
||||||
bridge-15.png
|
|
||||||
level-16.png
|
|
||||||
level-17.png
|
|
||||||
stage-18.png
|
|
||||||
level-19.png
|
|
||||||
level-21.png
|
|
||||||
level-22.png
|
|
||||||
stage-24.png
|
|
||||||
level-25.png
|
|
||||||
bridge-27.png
|
|
||||||
level-28.png
|
|
||||||
level-29.png
|
|
||||||
stage-30.png
|
|
||||||
```
|
|
||||||
|
|
||||||
视频 agent 应锁定镜头、构图和人物位置,只在相邻关键图之间做外观演化;不要加入推拉、横移、眨眼、转头、嘴部动作或随机肢体运动。输出仍按当前 UI 使用的 1:1 方形画幅制作,左侧留白和人物右移由 UI 的裁切、定位与渐变遮罩完成。
|
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ final result: passed
|
|||||||
|
|
||||||
- 24/24 portrait sources are present at `assets/portrait-source-v2/`
|
- 24/24 portrait sources are present at `assets/portrait-source-v2/`
|
||||||
- Final contact sheet: `assets/portrait-source-v2/references/contact-sheet-final-24.png`
|
- Final contact sheet: `assets/portrait-source-v2/references/contact-sheet-final-24.png`
|
||||||
- Generation/video handoff: `assets/portrait-source-v2/GENERATION-BRIEF.md`
|
- Portrait generation handoff: `assets/portrait-source-v2/GENERATION-BRIEF.md`
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop video,
|
|
||||||
.liang-skin-backdrop img {
|
.liang-skin-backdrop img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -63,7 +62,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="sequence"] video,
|
|
||||||
.liang-skin-backdrop[data-media="sequence"] .liang-skin-poster {
|
.liang-skin-backdrop[data-media="sequence"] .liang-skin-poster {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -73,12 +71,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
opacity: var(--liang-portrait-opacity, 0.82);
|
opacity: var(--liang-portrait-opacity, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="video"] .liang-skin-sequence-frame,
|
|
||||||
.liang-skin-backdrop[data-media="video"] .liang-skin-poster {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="poster"] video,
|
|
||||||
.liang-skin-backdrop[data-media="poster"] .liang-skin-sequence-frame {
|
.liang-skin-backdrop[data-media="poster"] .liang-skin-sequence-frame {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -87,7 +79,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="color"] video,
|
|
||||||
.liang-skin-backdrop[data-media="color"] img {
|
.liang-skin-backdrop[data-media="color"] img {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -652,7 +643,6 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
|||||||
width: 92px;
|
width: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop video,
|
|
||||||
.liang-skin-backdrop img {
|
.liang-skin-backdrop img {
|
||||||
right: -18vw;
|
right: -18vw;
|
||||||
width: 92vw;
|
width: 92vw;
|
||||||
@@ -663,7 +653,6 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.liang-skin-backdrop,
|
.liang-skin-backdrop,
|
||||||
.liang-skin-backdrop video,
|
|
||||||
.liang-skin-backdrop img,
|
.liang-skin-backdrop img,
|
||||||
body[data-liang-skin="on"] button,
|
body[data-liang-skin="on"] button,
|
||||||
body[data-liang-skin="on"] input,
|
body[data-liang-skin="on"] input,
|
||||||
@@ -797,7 +786,6 @@ var import_jsx_runtime = require("react/jsx-runtime");
|
|||||||
var PACKAGE_ID = "dsh-client-liang-intensity-skin";
|
var PACKAGE_ID = "dsh-client-liang-intensity-skin";
|
||||||
var LOCALE_NAMESPACE = "liang.skin";
|
var LOCALE_NAMESPACE = "liang.skin";
|
||||||
var ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
|
var ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
|
||||||
var VIDEO_DURATION = 8.033;
|
|
||||||
var BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
|
var BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
|
||||||
var PORTRAIT_ANCHORS = [
|
var PORTRAIT_ANCHORS = [
|
||||||
{ level: 0, file: "stage-00.png" },
|
{ level: 0, file: "stage-00.png" },
|
||||||
@@ -825,6 +813,7 @@ var PORTRAIT_ANCHORS = [
|
|||||||
{ level: 29, file: "level-29.png" },
|
{ level: 29, file: "level-29.png" },
|
||||||
{ level: 30, file: "stage-30.png" }
|
{ level: 30, file: "stage-30.png" }
|
||||||
];
|
];
|
||||||
|
var ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
|
||||||
var cssVariables = [
|
var cssVariables = [
|
||||||
"--liang-strength",
|
"--liang-strength",
|
||||||
"--liang-page",
|
"--liang-page",
|
||||||
@@ -846,10 +835,10 @@ var SkinPresenter = class {
|
|||||||
scope;
|
scope;
|
||||||
theme;
|
theme;
|
||||||
root;
|
root;
|
||||||
video;
|
|
||||||
poster;
|
poster;
|
||||||
portrait;
|
portrait;
|
||||||
preloads;
|
preloads;
|
||||||
|
portraitReady = false;
|
||||||
enabled = true;
|
enabled = true;
|
||||||
// Default to the max frame so the first paint after load is the dark shell;
|
// 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.
|
// starting at 0 flashed the light palette before the directory resolved.
|
||||||
@@ -864,19 +853,8 @@ var SkinPresenter = class {
|
|||||||
this.root = document.createElement("div");
|
this.root = document.createElement("div");
|
||||||
this.root.className = "liang-skin-backdrop";
|
this.root.className = "liang-skin-backdrop";
|
||||||
this.root.dataset.plugin = PACKAGE_ID;
|
this.root.dataset.plugin = PACKAGE_ID;
|
||||||
this.root.dataset.media = "sequence";
|
this.root.dataset.media = "poster";
|
||||||
this.root.setAttribute("aria-hidden", "true");
|
this.root.setAttribute("aria-hidden", "true");
|
||||||
this.video = document.createElement("video");
|
|
||||||
this.video.muted = true;
|
|
||||||
this.video.playsInline = true;
|
|
||||||
this.video.preload = "auto";
|
|
||||||
const webm = document.createElement("source");
|
|
||||||
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
|
|
||||||
webm.type = "video/webm";
|
|
||||||
const mp4 = document.createElement("source");
|
|
||||||
mp4.src = `${ASSET_PREFIX}/liang-evolution.mp4`;
|
|
||||||
mp4.type = "video/mp4";
|
|
||||||
this.video.append(webm, mp4);
|
|
||||||
this.poster = document.createElement("img");
|
this.poster = document.createElement("img");
|
||||||
this.poster.className = "liang-skin-poster";
|
this.poster.className = "liang-skin-poster";
|
||||||
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
|
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
|
||||||
@@ -885,29 +863,34 @@ var SkinPresenter = class {
|
|||||||
this.portrait.className = "liang-skin-sequence-frame";
|
this.portrait.className = "liang-skin-sequence-frame";
|
||||||
this.portrait.alt = "";
|
this.portrait.alt = "";
|
||||||
this.portrait.draggable = false;
|
this.portrait.draggable = false;
|
||||||
|
this.portrait.decoding = "async";
|
||||||
this.portrait.addEventListener("error", this.handleSequenceError);
|
this.portrait.addEventListener("error", this.handleSequenceError);
|
||||||
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
image.loading = "eager";
|
||||||
|
image.decoding = "async";
|
||||||
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
||||||
return image;
|
return image;
|
||||||
});
|
});
|
||||||
this.root.append(this.video, this.portrait, this.poster);
|
void Promise.all(this.preloads.map((image) => image.decode())).then(
|
||||||
|
() => {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.portraitReady = true;
|
||||||
|
this.root.dataset.media = "sequence";
|
||||||
|
this.updatePortrait(paletteForFrame(this.frame).level);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!this.disposed) this.root.dataset.media = "poster";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.root.append(this.portrait, this.poster);
|
||||||
document.body.prepend(this.root);
|
document.body.prepend(this.root);
|
||||||
this.video.addEventListener("loadedmetadata", this.handleMetadata);
|
|
||||||
this.video.addEventListener("error", this.handleVideoError);
|
|
||||||
this.poster.addEventListener("error", this.handlePosterError);
|
this.poster.addEventListener("error", this.handlePosterError);
|
||||||
this.video.load();
|
|
||||||
this.unsubscribe = scope.subscribe(() => this.syncSettings());
|
this.unsubscribe = scope.subscribe(() => this.syncSettings());
|
||||||
this.syncSettings();
|
this.syncSettings();
|
||||||
}
|
}
|
||||||
handleMetadata = () => {
|
|
||||||
this.seek(this.frame);
|
|
||||||
};
|
|
||||||
handleVideoError = () => {
|
|
||||||
this.root.dataset.media = "poster";
|
|
||||||
};
|
|
||||||
handleSequenceError = () => {
|
handleSequenceError = () => {
|
||||||
this.root.dataset.media = "video";
|
this.root.dataset.media = "poster";
|
||||||
};
|
};
|
||||||
handlePosterError = () => {
|
handlePosterError = () => {
|
||||||
this.root.dataset.media = "color";
|
this.root.dataset.media = "color";
|
||||||
@@ -966,16 +949,16 @@ var SkinPresenter = class {
|
|||||||
body.style.setProperty("--liang-hover", palette.hover);
|
body.style.setProperty("--liang-hover", palette.hover);
|
||||||
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
|
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
|
||||||
this.updatePortrait(palette.level);
|
this.updatePortrait(palette.level);
|
||||||
this.seek(this.frame);
|
|
||||||
}
|
}
|
||||||
syncNativeTheme(theme = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
|
syncNativeTheme(theme = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
|
||||||
if (!this.enabled || this.theme.getTheme().preference === theme) return;
|
if (!this.enabled || this.theme.getTheme().preference === theme) return;
|
||||||
this.theme.setTheme(theme);
|
this.theme.setTheme(theme);
|
||||||
}
|
}
|
||||||
updatePortrait(level) {
|
updatePortrait(level) {
|
||||||
|
if (!this.portraitReady) return;
|
||||||
const { lowerIndex, upperIndex, mix: mix2 } = portraitBlendForLevel(
|
const { lowerIndex, upperIndex, mix: mix2 } = portraitBlendForLevel(
|
||||||
level,
|
level,
|
||||||
PORTRAIT_ANCHORS.map((anchor) => anchor.level)
|
ANCHOR_LEVELS
|
||||||
);
|
);
|
||||||
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
||||||
const upper = PORTRAIT_ANCHORS[upperIndex];
|
const upper = PORTRAIT_ANCHORS[upperIndex];
|
||||||
@@ -983,12 +966,6 @@ var SkinPresenter = class {
|
|||||||
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
|
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
|
||||||
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
|
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
|
||||||
}
|
}
|
||||||
seek(frame) {
|
|
||||||
if (this.video.readyState < 1 || this.video.duration === 0) return;
|
|
||||||
const duration = Number.isFinite(this.video.duration) ? this.video.duration : VIDEO_DURATION;
|
|
||||||
const target = Math.min(Math.max(0, duration - 1e-3), frame / PREVIEW_MAX_FRAME * duration);
|
|
||||||
if (Math.abs(this.video.currentTime - target) > 0.025) this.video.currentTime = target;
|
|
||||||
}
|
|
||||||
async choose(enabled) {
|
async choose(enabled) {
|
||||||
this.setEnabled(enabled);
|
this.setEnabled(enabled);
|
||||||
try {
|
try {
|
||||||
@@ -1003,9 +980,6 @@ var SkinPresenter = class {
|
|||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.unsubscribe();
|
this.unsubscribe();
|
||||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||||
this.video.pause();
|
|
||||||
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
|
|
||||||
this.video.removeEventListener("error", this.handleVideoError);
|
|
||||||
this.portrait.removeEventListener("error", this.handleSequenceError);
|
this.portrait.removeEventListener("error", this.handleSequenceError);
|
||||||
this.poster.removeEventListener("error", this.handlePosterError);
|
this.poster.removeEventListener("error", this.handlePosterError);
|
||||||
for (const image of this.preloads) image.src = "";
|
for (const image of this.preloads) image.src = "";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -17,8 +17,6 @@
|
|||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"assets/liang-evolution.mp4",
|
|
||||||
"assets/liang-evolution.webm",
|
|
||||||
"assets/liang-poster.png",
|
"assets/liang-poster.png",
|
||||||
"assets/portrait-source-v2/stage-00.png",
|
"assets/portrait-source-v2/stage-00.png",
|
||||||
"assets/portrait-source-v2/level-01.png",
|
"assets/portrait-source-v2/level-01.png",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
const PACKAGE_ID = "dsh-client-liang-intensity-skin";
|
const PACKAGE_ID = "dsh-client-liang-intensity-skin";
|
||||||
const LOCALE_NAMESPACE = "liang.skin";
|
const LOCALE_NAMESPACE = "liang.skin";
|
||||||
const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
|
const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
|
||||||
const VIDEO_DURATION = 8.033;
|
|
||||||
const BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
|
const BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
|
||||||
|
|
||||||
const PORTRAIT_ANCHORS = [
|
const PORTRAIT_ANCHORS = [
|
||||||
@@ -54,6 +53,8 @@ const PORTRAIT_ANCHORS = [
|
|||||||
{ level: 30, file: "stage-30.png" },
|
{ level: 30, file: "stage-30.png" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
|
||||||
|
|
||||||
interface SkinSettings {
|
interface SkinSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
bindEffort: boolean;
|
bindEffort: boolean;
|
||||||
@@ -139,10 +140,10 @@ class SkinPresenter {
|
|||||||
private readonly scope: PreferenceStore;
|
private readonly scope: PreferenceStore;
|
||||||
private readonly theme: ThemeService;
|
private readonly theme: ThemeService;
|
||||||
private readonly root: HTMLDivElement;
|
private readonly root: HTMLDivElement;
|
||||||
private readonly video: HTMLVideoElement;
|
|
||||||
private readonly poster: HTMLImageElement;
|
private readonly poster: HTMLImageElement;
|
||||||
private readonly portrait: HTMLImageElement;
|
private readonly portrait: HTMLImageElement;
|
||||||
private readonly preloads: HTMLImageElement[];
|
private readonly preloads: HTMLImageElement[];
|
||||||
|
private portraitReady = false;
|
||||||
private enabled = true;
|
private enabled = true;
|
||||||
// Default to the max frame so the first paint after load is the dark shell;
|
// 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.
|
// starting at 0 flashed the light palette before the directory resolved.
|
||||||
@@ -158,24 +159,12 @@ class SkinPresenter {
|
|||||||
this.root = document.createElement("div");
|
this.root = document.createElement("div");
|
||||||
this.root.className = "liang-skin-backdrop";
|
this.root.className = "liang-skin-backdrop";
|
||||||
this.root.dataset.plugin = PACKAGE_ID;
|
this.root.dataset.plugin = PACKAGE_ID;
|
||||||
this.root.dataset.media = "sequence";
|
// Keep the stable poster visible while every portrait frame is fetched
|
||||||
|
// and decoded. A request being complete does not mean the bitmap is ready
|
||||||
|
// for a tear-free first swap.
|
||||||
|
this.root.dataset.media = "poster";
|
||||||
this.root.setAttribute("aria-hidden", "true");
|
this.root.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
this.video = document.createElement("video");
|
|
||||||
this.video.muted = true;
|
|
||||||
this.video.playsInline = true;
|
|
||||||
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.
|
|
||||||
const webm = document.createElement("source");
|
|
||||||
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
|
|
||||||
webm.type = "video/webm";
|
|
||||||
const mp4 = document.createElement("source");
|
|
||||||
mp4.src = `${ASSET_PREFIX}/liang-evolution.mp4`;
|
|
||||||
mp4.type = "video/mp4";
|
|
||||||
this.video.append(webm, mp4);
|
|
||||||
|
|
||||||
this.poster = document.createElement("img");
|
this.poster = document.createElement("img");
|
||||||
this.poster.className = "liang-skin-poster";
|
this.poster.className = "liang-skin-poster";
|
||||||
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
|
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
|
||||||
@@ -185,35 +174,43 @@ class SkinPresenter {
|
|||||||
this.portrait.className = "liang-skin-sequence-frame";
|
this.portrait.className = "liang-skin-sequence-frame";
|
||||||
this.portrait.alt = "";
|
this.portrait.alt = "";
|
||||||
this.portrait.draggable = false;
|
this.portrait.draggable = false;
|
||||||
|
this.portrait.decoding = "async";
|
||||||
this.portrait.addEventListener("error", this.handleSequenceError);
|
this.portrait.addEventListener("error", this.handleSequenceError);
|
||||||
|
|
||||||
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
image.loading = "eager";
|
||||||
|
image.decoding = "async";
|
||||||
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
||||||
return image;
|
return image;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.root.append(this.video, this.portrait, this.poster);
|
// `new Image()` starts the requests, but the browser may still defer
|
||||||
|
// decoding until the image is attached to the document. Wait for all
|
||||||
|
// frames up front so the first slider interaction never reveals a blank
|
||||||
|
// or half-painted frame.
|
||||||
|
void Promise.all(this.preloads.map((image) => image.decode())).then(
|
||||||
|
() => {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.portraitReady = true;
|
||||||
|
this.root.dataset.media = "sequence";
|
||||||
|
this.updatePortrait(paletteForFrame(this.frame).level);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!this.disposed) this.root.dataset.media = "poster";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.root.append(this.portrait, this.poster);
|
||||||
document.body.prepend(this.root);
|
document.body.prepend(this.root);
|
||||||
|
|
||||||
this.video.addEventListener("loadedmetadata", this.handleMetadata);
|
|
||||||
this.video.addEventListener("error", this.handleVideoError);
|
|
||||||
this.poster.addEventListener("error", this.handlePosterError);
|
this.poster.addEventListener("error", this.handlePosterError);
|
||||||
this.video.load();
|
|
||||||
this.unsubscribe = scope.subscribe(() => this.syncSettings());
|
this.unsubscribe = scope.subscribe(() => this.syncSettings());
|
||||||
this.syncSettings();
|
this.syncSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly handleMetadata = () => {
|
|
||||||
this.seek(this.frame);
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly handleVideoError = () => {
|
|
||||||
this.root.dataset.media = "poster";
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly handleSequenceError = () => {
|
private readonly handleSequenceError = () => {
|
||||||
this.root.dataset.media = "video";
|
this.root.dataset.media = "poster";
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly handlePosterError = () => {
|
private readonly handlePosterError = () => {
|
||||||
@@ -279,7 +276,6 @@ class SkinPresenter {
|
|||||||
body.style.setProperty("--liang-hover", palette.hover);
|
body.style.setProperty("--liang-hover", palette.hover);
|
||||||
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
|
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
|
||||||
this.updatePortrait(palette.level);
|
this.updatePortrait(palette.level);
|
||||||
this.seek(this.frame);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncNativeTheme(theme: NativeThemeId = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
|
syncNativeTheme(theme: NativeThemeId = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
|
||||||
@@ -288,9 +284,10 @@ class SkinPresenter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updatePortrait(level: number) {
|
private updatePortrait(level: number) {
|
||||||
|
if (!this.portraitReady) return;
|
||||||
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
|
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
|
||||||
level,
|
level,
|
||||||
PORTRAIT_ANCHORS.map((anchor) => anchor.level),
|
ANCHOR_LEVELS,
|
||||||
);
|
);
|
||||||
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
||||||
const upper = PORTRAIT_ANCHORS[upperIndex];
|
const upper = PORTRAIT_ANCHORS[upperIndex];
|
||||||
@@ -299,13 +296,6 @@ class SkinPresenter {
|
|||||||
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
|
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
private seek(frame: number) {
|
|
||||||
if (this.video.readyState < 1 || this.video.duration === 0) return;
|
|
||||||
const duration = Number.isFinite(this.video.duration) ? this.video.duration : VIDEO_DURATION;
|
|
||||||
const target = Math.min(Math.max(0, duration - 0.001), (frame / PREVIEW_MAX_FRAME) * duration);
|
|
||||||
if (Math.abs(this.video.currentTime - target) > 0.025) this.video.currentTime = target;
|
|
||||||
}
|
|
||||||
|
|
||||||
async choose(enabled: boolean) {
|
async choose(enabled: boolean) {
|
||||||
this.setEnabled(enabled);
|
this.setEnabled(enabled);
|
||||||
try {
|
try {
|
||||||
@@ -321,9 +311,6 @@ class SkinPresenter {
|
|||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.unsubscribe();
|
this.unsubscribe();
|
||||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||||
this.video.pause();
|
|
||||||
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
|
|
||||||
this.video.removeEventListener("error", this.handleVideoError);
|
|
||||||
this.portrait.removeEventListener("error", this.handleSequenceError);
|
this.portrait.removeEventListener("error", this.handleSequenceError);
|
||||||
this.poster.removeEventListener("error", this.handlePosterError);
|
this.poster.removeEventListener("error", this.handlePosterError);
|
||||||
for (const image of this.preloads) image.src = "";
|
for (const image of this.preloads) image.src = "";
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop video,
|
|
||||||
.liang-skin-backdrop img {
|
.liang-skin-backdrop img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -34,7 +33,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="sequence"] video,
|
|
||||||
.liang-skin-backdrop[data-media="sequence"] .liang-skin-poster {
|
.liang-skin-backdrop[data-media="sequence"] .liang-skin-poster {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -44,12 +42,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
opacity: var(--liang-portrait-opacity, 0.82);
|
opacity: var(--liang-portrait-opacity, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="video"] .liang-skin-sequence-frame,
|
|
||||||
.liang-skin-backdrop[data-media="video"] .liang-skin-poster {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="poster"] video,
|
|
||||||
.liang-skin-backdrop[data-media="poster"] .liang-skin-sequence-frame {
|
.liang-skin-backdrop[data-media="poster"] .liang-skin-sequence-frame {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -58,7 +50,6 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="color"] video,
|
|
||||||
.liang-skin-backdrop[data-media="color"] img {
|
.liang-skin-backdrop[data-media="color"] img {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -623,7 +614,6 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
|||||||
width: 92px;
|
width: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop video,
|
|
||||||
.liang-skin-backdrop img {
|
.liang-skin-backdrop img {
|
||||||
right: -18vw;
|
right: -18vw;
|
||||||
width: 92vw;
|
width: 92vw;
|
||||||
@@ -634,7 +624,6 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.liang-skin-backdrop,
|
.liang-skin-backdrop,
|
||||||
.liang-skin-backdrop video,
|
|
||||||
.liang-skin-backdrop img,
|
.liang-skin-backdrop img,
|
||||||
body[data-liang-skin="on"] button,
|
body[data-liang-skin="on"] button,
|
||||||
body[data-liang-skin="on"] input,
|
body[data-liang-skin="on"] input,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const SETTINGS_NAMESPACE = "liang-intensity-skin";
|
|||||||
export const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets/`;
|
export const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets/`;
|
||||||
|
|
||||||
const ASSET_SPECS = [
|
const ASSET_SPECS = [
|
||||||
["liang-evolution.webm", "video/webm"],
|
|
||||||
["liang-evolution.mp4", "video/mp4"],
|
|
||||||
["liang-poster.png", "image/png"],
|
["liang-poster.png", "image/png"],
|
||||||
["portrait-source-v2/stage-00.png", "image/png"],
|
["portrait-source-v2/stage-00.png", "image/png"],
|
||||||
["portrait-source-v2/level-01.png", "image/png"],
|
["portrait-source-v2/level-01.png", "image/png"],
|
||||||
|
|||||||
Reference in New Issue
Block a user