diff --git a/SpineThumbnailGenerateServer/index.html b/SpineThumbnailGenerateServer/index.html
index 6792a2b..aca502b 100644
--- a/SpineThumbnailGenerateServer/index.html
+++ b/SpineThumbnailGenerateServer/index.html
@@ -13,18 +13,25 @@
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.notice b { color: var(--text); font-weight: 500; }
.notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; }
- .toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer;
+ .toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
+ flex: 1 1 240px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
- .toolbar { display: flex; gap: 8px; }
- .toolbar .toggle-row { flex: 1; }
+ /* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
+ .toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
+ .toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
- border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; }
+ border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
#clear:hover { border-color: var(--accent); color: var(--accent); }
+ .mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
+ flex: 0 1 auto;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
+ .mode-row select { background: var(--panel); color: var(--text); border: 1px solid var(--border);
+ border-radius: 6px; padding: 3px 6px; font: inherit; max-width: 200px; }
.row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
.row b { color: var(--text); font-weight: 500; word-break: break-all; }
#stats { color: var(--accent); }
- #log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none;
+ #log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none; min-height: 140px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; }
#log li { white-space: pre-wrap; word-break: break-all; }
@@ -50,11 +57,29 @@
导入 Spine 资产(.skel / .json + .atlas + 贴图)后,
本插件会为还没设置缩略图的素材生成一张骨骼预览图。
它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。
+ 生成方式可切换:动态取骨骼里的第一个动画压成 WebP(200×200,体积按动画时长自动定,
+ 参数与主插件默认值一致),静帧始终用 setup pose。
自动生成默认关闭,需要你在下面手动启用。
状态启动中…
素材库—
@@ -92,6 +117,9 @@
+
+
+
diff --git a/SpineThumbnailGenerateServer/js/animated-profiles.js b/SpineThumbnailGenerateServer/js/animated-profiles.js
new file mode 100644
index 0000000..02529fd
--- /dev/null
+++ b/SpineThumbnailGenerateServer/js/animated-profiles.js
@@ -0,0 +1,102 @@
+// 动态缩略图的档位配置。数值与主插件 js/animated-profiles.js 保持一致,
+// 后台插件没有参数面板,所以这里只当默认值来源,改这里就等于改后台插件的默认渲染参数。
+(function () {
+'use strict';
+
+/**
+ * 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
+ * - 细节分辨率决定画面保留多少细节(输出画布始终是画布边长,小分辨率会被放大填满);
+ * - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
+ * - 编码质量是 0–1 的 WebP 质量。
+ *
+ * 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
+ * 路径,再按目标体积在路径上定位,取命中位置附近若干档完整生成,挑出符合体积的那一档。
+ */
+const ANIMATED_PRIORITIES = {
+ // 分辨率优先:先把帧率压到底,再降分辨率。
+ resolution: [
+ [400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
+ [200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
+ ],
+ // 帧率优先:先把分辨率压到底,再降帧率。
+ framerate: [
+ [400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
+ [300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
+ ],
+ // 交替进行:分辨率和帧率交替下降。
+ balanced: [
+ [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
+ [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
+ ],
+};
+
+const DEFAULT_PRIORITY = 'resolution';
+
+/** 可选的输出画布边长(正方形)。后台插件默认用 200。 */
+const ANIMATED_OUTPUT_SIZES = [200, 400];
+const DEFAULT_OUTPUT_SIZE = 200;
+
+/** 目标体积的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。 */
+const SIZE_PER_SECOND = 25;
+const SIZE_SUGGESTION_RANGE = [25, 100];
+
+// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,粗估阶段耗时略增。
+const PATH_STEPS_PER_SEGMENT = 6;
+
+function suggestSize(seconds) {
+ const [min, max] = SIZE_SUGGESTION_RANGE;
+ return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
+}
+
+function animatedProfiles(priority, outputSize) {
+ const limit = outputSize || DEFAULT_OUTPUT_SIZE;
+ const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
+ return plan.map(([size, fps, quality]) => ({
+ size: Math.min(size, limit),
+ fps,
+ quality,
+ }));
+}
+
+/**
+ * 把锚点路径插值成更密的采样路径,供按目标体积定位使用。
+ * 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
+ */
+function expandAnimatedPath(priority, outputSize) {
+ const limit = outputSize || DEFAULT_OUTPUT_SIZE;
+ const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
+ const lerp = (from, to, t) => from + (to - from) * t;
+ const path = [];
+ for (let index = 0; index < anchors.length - 1; index++) {
+ const [fromSize, fromFps, fromQuality] = anchors[index];
+ const [toSize, toFps, toQuality] = anchors[index + 1];
+ for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
+ const t = step / PATH_STEPS_PER_SEGMENT;
+ path.push({
+ size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
+ fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
+ quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
+ });
+ }
+ }
+ const [size, fps, quality] = anchors[anchors.length - 1];
+ path.push({ size: Math.min(size, limit), fps, quality });
+ return path;
+}
+
+const api = {
+ ANIMATED_PRIORITIES,
+ DEFAULT_PRIORITY,
+ ANIMATED_OUTPUT_SIZES,
+ DEFAULT_OUTPUT_SIZE,
+ SIZE_PER_SECOND,
+ SIZE_SUGGESTION_RANGE,
+ PATH_STEPS_PER_SEGMENT,
+ animatedProfiles,
+ expandAnimatedPath,
+ suggestSize,
+};
+if (typeof window !== 'undefined') window.SpineAnimatedProfiles = api;
+else if (typeof module !== 'undefined' && module.exports) module.exports = api;
+
+})();
diff --git a/SpineThumbnailGenerateServer/js/animated-webp-encoder.js b/SpineThumbnailGenerateServer/js/animated-webp-encoder.js
new file mode 100644
index 0000000..7badb15
--- /dev/null
+++ b/SpineThumbnailGenerateServer/js/animated-webp-encoder.js
@@ -0,0 +1,85 @@
+// 把若干张 WebP 静帧封装成一个动态 WebP(ANMF 全帧、不混合)。
+// 与主插件 js/animated-webp-encoder.js 是同一份实现,只是出口兼容