dikdimon commited on
Commit
c5e6503
·
verified ·
1 Parent(s): f7e775e

Upload 8 files

Browse files
sd-webui-timemachine-fixed/javascript/init.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function(NAME) {
2
+
3
+ const name = NAME.toLowerCase().replaceAll(/\s/g, '');
4
+
5
+ let _r = 0;
6
+ function to_gradio(v) {
7
+ // force call `change` event on gradio
8
+ return [v.toString(), (_r++).toString()];
9
+ }
10
+
11
+ function js2py(type, gradio_field, value) {
12
+ // set `value` to gradio's field
13
+ // (1) Click gradio's button.
14
+ // (2) Gradio will fire js callback to retrieve value to be set.
15
+ // (3) Gradio will fire another js callback to notify the process has been completed.
16
+ return new Promise(resolve => {
17
+ const callback_name = `${name}-${type}-${gradio_field}`;
18
+
19
+ // (2)
20
+ globalThis[callback_name] = () => {
21
+
22
+ delete globalThis[callback_name];
23
+
24
+ // (3)
25
+ const callback_after = callback_name + '_after';
26
+ globalThis[callback_after] = () => {
27
+ delete globalThis[callback_after];
28
+ resolve();
29
+ };
30
+
31
+ return to_gradio(value);
32
+ };
33
+
34
+ // (1)
35
+ gradioApp().querySelector(`#${callback_name}_set`).click();
36
+ });
37
+ }
38
+
39
+ function id(mode, s) {
40
+ const v = `${name}-${mode}`;
41
+ return s === undefined ? v : `${v}-${s}`;
42
+ }
43
+
44
+ if (!globalThis[name]) {
45
+ globalThis[name] = {};
46
+ }
47
+
48
+ const obj = globalThis[name];
49
+ obj.id = id;
50
+ obj.js2py = js2py;
51
+ obj.init = true;
52
+
53
+ console.log(`[${NAME}] initialized`)
54
+ document.dispatchEvent(new CustomEvent(`${name}_init`, { detail: obj }));
55
+
56
+ })('TimeMachine');
sd-webui-timemachine-fixed/javascript/modules/chart.umd.js ADDED
The diff for this file is too large to render. See raw diff
 
sd-webui-timemachine-fixed/javascript/modules/chart.umd.js.map ADDED
The diff for this file is too large to render. See raw diff
 
sd-webui-timemachine-fixed/javascript/timemachine.js ADDED
@@ -0,0 +1,951 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function (NAME) {
2
+
3
+ const name = NAME.toLowerCase().replaceAll(/\s/g, '');
4
+
5
+ if (globalThis[name]?.init) init(name, globalThis[name]);
6
+ else document.addEventListener(`${name}_init`, e => init(name, e.detail), { once: true });
7
+
8
+ async function init(name, lib) { await load_modules(lib); await main(name, lib); }
9
+
10
+ function load_modules(lib) {
11
+ return new Promise(resolve => {
12
+ function load() {
13
+ if (lib.module_loaded) return true;
14
+ const app = gradioApp();
15
+ if (!app || app === document) return false;
16
+ const jscont = app.querySelector('#' + lib.id('js_modules'));
17
+ if (!jscont) return false;
18
+ const [base_path, ...scripts] = jscont.textContent.trim().split('\n').map(x => x.trim());
19
+ jscont.textContent = '';
20
+ const df = document.createDocumentFragment();
21
+ for (let src of scripts) {
22
+ const s = document.createElement('script');
23
+ s.async = true; s.type = 'module'; s.src = `file=${src}`; df.appendChild(s);
24
+ }
25
+ app.appendChild(df);
26
+ lib.import = s => import(`/file=${base_path}/javascript/modules/${s}`);
27
+ lib.module_loaded = true; resolve(); return true;
28
+ }
29
+ (function try_load() { if (!load()) setTimeout(try_load, 500); })();
30
+ });
31
+ }
32
+
33
+ async function main(name, lib) {
34
+ await lib.import('chart.umd.js');
35
+ main2(name, lib, 'txt2img');
36
+ main2(name, lib, 'img2img');
37
+ }
38
+
39
+ async function main2(name, lib, mode) {
40
+ // Ждём появления аккордеона в DOM
41
+ await new Promise(resolve => {
42
+ (function try_get() {
43
+ const el = gradioApp().querySelector('#' + lib.id(mode, 'accordion'));
44
+ el ? resolve(el) : setTimeout(try_get, 500);
45
+ })();
46
+ });
47
+
48
+ let initialized = false;
49
+
50
+ function tryInit() {
51
+ if (initialized) return;
52
+ const container = gradioApp().querySelector('#' + lib.id(mode, 'container'));
53
+ // offsetParent === null если элемент скрыт (display:none)
54
+ if (container && container.offsetParent !== null) {
55
+ initialized = true;
56
+ main3(name, lib, mode);
57
+ }
58
+ }
59
+
60
+ // Попытка сразу (Gradio 4.x: контент уже в DOM, просто скрыт)
61
+ tryInit();
62
+
63
+ // MutationObserver для Gradio 3.x (контент добавляется при открытии)
64
+ const acc = gradioApp().querySelector('#' + lib.id(mode, 'accordion'));
65
+ if (acc) {
66
+ new MutationObserver(() => tryInit())
67
+ .observe(acc, { childList: true, subtree: true, attributes: true });
68
+ }
69
+
70
+ // Опрос как запасной вариант (ловит CSS-изменения видимости)
71
+ // Останавливается как только граф инициализирован или через 60 сек
72
+ const deadline = Date.now() + 60000;
73
+ const poll = setInterval(() => {
74
+ if (initialized || Date.now() > deadline) { clearInterval(poll); return; }
75
+ tryInit();
76
+ }, 300);
77
+ }
78
+
79
+ function main3(name, lib, mode) {
80
+ const id = s => lib.id(mode, s);
81
+ const $ = x => Array.from(gradioApp().querySelectorAll(x)).at(-1);
82
+ const $$ = x => gradioApp().querySelector(x);
83
+
84
+ const enabled = $$(`#${id('enabled')} input[type=checkbox]`);
85
+ const generate_button = $$(`#${mode}_generate`);
86
+ const step_ele = $$(`#${mode}_steps input[type=number]`);
87
+ const oneShotEl = $$(`#${id('one_shot')} input[type=checkbox]`);
88
+ const cutoffEl = $$(`#${id('cutoff')} input[type=number]`);
89
+
90
+ // ── Главный график ──────────────────────────────────
91
+ const canvas = document.createElement('canvas');
92
+ canvas.width = 512; canvas.height = 512;
93
+
94
+ const plugins = createPlugins(canvas, step_ele, cutoffEl);
95
+ const chart = new Chart(canvas.getContext('2d'), {
96
+ type: 'scatter', data: createInitialData(),
97
+ options: createChartOption(), plugins,
98
+ });
99
+ $('#' + id('container')).appendChild(canvas);
100
+
101
+ // ── Per-segment mode: right-click on line → menu ─────────
102
+ const SEG_MODES = ['Linear', 'Ease In', 'Ease Out', 'Ease In-Out', 'Cubic', 'Exponential', 'Step'];
103
+
104
+ function _findSegment(c, ev) {
105
+ const rect = c.canvas.getBoundingClientRect();
106
+ const x = c.scales.x.getValueForPixel(ev.clientX - rect.left);
107
+ const data = c.data.datasets[0].data;
108
+ for (let i = 0; i < data.length - 1; i++) {
109
+ if (data[i].x <= x && x <= data[i + 1].x) return { idx: i, pt: data[i] };
110
+ }
111
+ return null;
112
+ }
113
+
114
+ let _segMenuCloser = null;
115
+ function _closeSegMenu() {
116
+ const old = document.getElementById(id('segmenu')); if (old) old.remove();
117
+ if (_segMenuCloser) { document.removeEventListener('click', _segMenuCloser); _segMenuCloser = null; }
118
+ }
119
+
120
+ function _showSegMenu(c, seg, ev) {
121
+ _closeSegMenu();
122
+ const div = document.createElement('div');
123
+ div.id = id('segmenu');
124
+ div.style.cssText = 'position:fixed;background:#1a1a2e;border:1px solid #ff8c00;border-radius:8px;padding:4px 0;z-index:99999;font-size:13px;box-shadow:0 4px 20px rgba(0,0,0,.6);min-width:150px';
125
+ _segMenuCloser = () => _closeSegMenu();
126
+ document.addEventListener('click', _segMenuCloser, {once:true});
127
+
128
+ SEG_MODES.forEach(m => {
129
+ const el = document.createElement('div');
130
+ el.textContent = m;
131
+ const active = seg.pt.mode === m || (!seg.pt.mode && m === 'Linear');
132
+ el.style.cssText = `padding:4px 16px;cursor:pointer;color:${active?'#ff8c00':'#ddd'};font-weight:${active?'bold':'normal'}`;
133
+ el.addEventListener('mouseenter', () => { el.style.background = '#ff8c0022'; });
134
+ el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
135
+ el.addEventListener('click', e => { e.stopPropagation();
136
+ if (m === 'Linear') delete seg.pt.mode; else seg.pt.mode = m;
137
+ c.update(); saveState(c); triggerSync(c); _closeSegMenu();
138
+ });
139
+ div.appendChild(el);
140
+ });
141
+
142
+ const sep = document.createElement('div');
143
+ sep.style.cssText = 'height:1px;background:#444;margin:3px 8px';
144
+ div.appendChild(sep);
145
+
146
+ const clear = document.createElement('div');
147
+ clear.textContent = '— Use global';
148
+ clear.style.cssText = 'padding:4px 16px;cursor:pointer;color:#999;font-style:italic';
149
+ clear.addEventListener('mouseenter', () => { clear.style.background = '#ff8c0022'; });
150
+ clear.addEventListener('mouseleave', () => { clear.style.background = 'transparent'; });
151
+ clear.addEventListener('click', e => { e.stopPropagation();
152
+ delete seg.pt.mode; c.update(); saveState(c); triggerSync(c); _closeSegMenu();
153
+ });
154
+ div.appendChild(clear);
155
+
156
+ div.style.left = Math.min(ev.clientX, window.innerWidth - 170) + 'px';
157
+ div.style.top = Math.min(ev.clientY, window.innerHeight - div.offsetHeight) + 'px';
158
+ document.body.appendChild(div);
159
+ }
160
+
161
+ canvas.addEventListener('contextmenu', e => {
162
+ e.preventDefault();
163
+ if (plugins[0].lastRemoved) { plugins[0].lastRemoved = false; return; }
164
+ const seg = _findSegment(chart, e);
165
+ if (seg) _showSegMenu(chart, seg, e);
166
+ }, true);
167
+
168
+ // σ-аппроксимация (реальные через HTTP, fallback Karras)
169
+ let showSigma = false, approxSigmas = [];
170
+
171
+ function getScheduler() {
172
+ const el = $$(`#${id('scheduler')} select`);
173
+ return el?.value || 'Use sampler default';
174
+ }
175
+
176
+ function getSampler() {
177
+ const el = $$(`#${mode}_sampling select`);
178
+ return el?.value || '';
179
+ }
180
+
181
+ async function _fetchRealSigmas(scheduler, steps) {
182
+ try {
183
+ let url = `/timemachine/sigmas?scheduler=${encodeURIComponent(scheduler)}&steps=${steps}`;
184
+ const sampler = getSampler();
185
+ if (sampler) url += `&sampler=${encodeURIComponent(sampler)}`;
186
+ const res = await fetch(url);
187
+ if (!res.ok) return null;
188
+ const data = await res.json();
189
+ return Array.isArray(data.sigmas) ? data.sigmas : null;
190
+ } catch(e) {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ function _karrasApprox(n) {
196
+ const [sMin, sMax, rho] = [0.1, 14.6, 7];
197
+ const sig = Array.from({length: n}, (_, i) => {
198
+ const t = i / Math.max(1, n-1);
199
+ return Math.pow(Math.pow(sMax,1/rho)+t*(Math.pow(sMin,1/rho)-Math.pow(sMax,1/rho)), rho);
200
+ });
201
+ sig.push(0);
202
+ return sig;
203
+ }
204
+
205
+ function updateSigmaApprox(n, scheduler) {
206
+ approxSigmas = _karrasApprox(n);
207
+ _fetchRealSigmas(scheduler, n).then(real => {
208
+ if (real && real.length >= n) {
209
+ // real has n+1 sigmas (including trailing 0)
210
+ approxSigmas = real.slice(0, n + 1);
211
+ if (showSigma) { chart?.update(); if (hr_chart) hr_chart?.update(); }
212
+ }
213
+ });
214
+ }
215
+
216
+ // Динамические callbacks Chart.js
217
+ chart.options.scales.y.ticks.callback = v => {
218
+ if (!showSigma || v < 1 || v > approxSigmas.length-1) return v;
219
+ const s = approxSigmas[Math.round(v)-1];
220
+ return s == null ? v : s < 0.01 ? '0' : s < 1 ? s.toFixed(3) : s.toFixed(1);
221
+ };
222
+ chart.options.plugins.tooltip.callbacks.title = ctxs => ctxs.map(c => {
223
+ const pos = c.parsed.y;
224
+ if (!showSigma) return `${c.parsed.x} → ${pos}`;
225
+ const s = approxSigmas[Math.round(pos)-1];
226
+ return `step ${c.parsed.x} → ${pos}${s != null ? ` (σ≈${s.toFixed(3)})` : ''}`;
227
+ });
228
+
229
+ // Interp tension
230
+ function getInterpMode() {
231
+ return $$(`#${id('interpolation')} select`)?.value || 'Linear';
232
+ }
233
+ function updateChartStyle(c = chart) {
234
+ const mode = getInterpMode();
235
+ const ds = c.data.datasets[0];
236
+ if (mode === 'Step') {
237
+ ds.stepped = 'before'; ds.cubicInterpolationMode = 'default'; ds.tension = 0;
238
+ } else if (mode === 'Monotone') {
239
+ ds.stepped = false; ds.cubicInterpolationMode = 'monotone'; ds.tension = 0;
240
+ } else if (mode === 'Smooth (spline)') {
241
+ ds.stepped = false; ds.cubicInterpolationMode = 'default'; ds.tension = 0.4;
242
+ } else {
243
+ ds.stepped = false; ds.cubicInterpolationMode = 'default'; ds.tension = 0;
244
+ }
245
+ c.update();
246
+ }
247
+ const interpEl = $$(`#${id('interpolation')} select`);
248
+ if (interpEl) interpEl.addEventListener('change', () => updateChartStyle());
249
+ const schedEl = $$(`#${id('scheduler')} select`);
250
+ if (schedEl) schedEl.addEventListener('change', () => {
251
+ updateSigmaApprox(+step_ele.value, getScheduler());
252
+ });
253
+ const samplerEl = $$(`#${mode}_sampler select`);
254
+ if (samplerEl) samplerEl.addEventListener('change', () => {
255
+ updateSigmaApprox(+step_ele.value, getScheduler());
256
+ });
257
+
258
+ // ── История (Undo/Redo) ──────────────────────────────
259
+ const MAX_HIST = 20;
260
+ let hist = [], histIdx = -1;
261
+
262
+ function saveState(c) {
263
+ const snap = JSON.stringify(c.data.datasets[0].data);
264
+ if (hist[histIdx] === snap) return;
265
+ hist = hist.slice(0, histIdx+1);
266
+ hist.push(snap);
267
+ if (hist.length > MAX_HIST) hist.shift(); else histIdx++;
268
+ }
269
+ function undo(c) {
270
+ if (histIdx > 0) {
271
+ histIdx--;
272
+ c.data.datasets[0].data = JSON.parse(hist[histIdx]);
273
+ c.update(); triggerSync(c);
274
+ }
275
+ }
276
+ function redo(c) {
277
+ if (histIdx < hist.length-1) {
278
+ histIdx++;
279
+ c.data.datasets[0].data = JSON.parse(hist[histIdx]);
280
+ c.update(); triggerSync(c);
281
+ }
282
+ }
283
+
284
+ document.addEventListener('keydown', e => {
285
+ const tag = document.activeElement?.tagName;
286
+ if (tag==='INPUT'||tag==='TEXTAREA'||document.activeElement?.contentEditable==='true') return;
287
+ if (!enabled?.checked) return;
288
+ if (e.ctrlKey && e.key==='z' && !e.shiftKey) { e.preventDefault(); undo(chart); }
289
+ if (e.ctrlKey && ((e.key==='z' && e.shiftKey)||e.key==='y')) { e.preventDefault(); redo(chart); }
290
+ });
291
+
292
+ // ── Sync + Persist ──────────────────────────────────
293
+ let syncedData = null, pendingSync = null;
294
+
295
+ function _saveToDisk() {
296
+ try {
297
+ localStorage.setItem(`tm_curve:${mode}`, JSON.stringify(chart.data.datasets[0].data));
298
+ if (hr_chart) {
299
+ localStorage.setItem(`tm_curve:${mode}:hr`, JSON.stringify(hr_chart.data.datasets[0].data));
300
+ }
301
+ } catch(e) { /* quota exceeded, silently ignore */ }
302
+ }
303
+
304
+ function _loadFromDisk() {
305
+ const saved = localStorage.getItem(`tm_curve:${mode}`);
306
+ if (saved) {
307
+ try {
308
+ const pts = JSON.parse(saved);
309
+ if (Array.isArray(pts) && pts.length >= 2) {
310
+ chart.data.datasets[0].data = pts;
311
+ updateSteps(chart, +step_ele.value);
312
+ syncedData = JSON.stringify(chart.data.datasets[0].data);
313
+ return;
314
+ }
315
+ } catch(e) { /* ignore corrupt data */ }
316
+ }
317
+ }
318
+
319
+ function triggerSync(c, key='tm') {
320
+ const data = JSON.stringify(c.data.datasets[0].data);
321
+ if (key==='tm' && data===syncedData) return;
322
+ if (key==='tm_hr' && data===hr_syncedData) return;
323
+ const prom = lib.js2py(mode, key, data).then(() => {
324
+ if (key==='tm') { syncedData = data; pendingSync = null; }
325
+ if (key==='tm_hr') { hr_syncedData = data; hr_pendingSync = null; }
326
+ }).catch(() => {
327
+ if (key==='tm') pendingSync = null;
328
+ if (key==='tm_hr') hr_pendingSync = null;
329
+ });
330
+ if (key==='tm') pendingSync = prom;
331
+ if (key==='tm_hr') hr_pendingSync = prom;
332
+ _saveToDisk();
333
+ }
334
+
335
+ // Hook плагина
336
+ const plugin = plugins[0];
337
+ const _oEnd = plugin.endDrag.bind(plugin);
338
+ plugin.endDrag = (c,a)=>{ _oEnd(c,a); saveState(c); triggerSync(c); };
339
+ const _oAdd = plugin.addPoint.bind(plugin);
340
+ plugin.addPoint = (c,a)=>{ _oAdd(c,a); saveState(c); triggerSync(c); };
341
+ const _oDel = plugin.removePoint.bind(plugin);
342
+ plugin.removePoint = (c,a)=>{ _oDel(c,a); saveState(c); triggerSync(c); };
343
+
344
+ // Инициализация
345
+ updateSteps(chart, +step_ele.value);
346
+ updateSigmaApprox(+step_ele.value, getScheduler());
347
+ _loadFromDisk();
348
+ saveState(chart);
349
+
350
+ let debounceTimer;
351
+ step_ele.addEventListener('input', () => {
352
+ updateSteps(chart, +step_ele.value);
353
+ updateSigmaApprox(+step_ele.value, getScheduler());
354
+ saveState(chart);
355
+ if (cutoffEl) cutoffEl.setAttribute('max', +step_ele.value);
356
+ clearTimeout(debounceTimer);
357
+ debounceTimer = setTimeout(() => triggerSync(chart), 150);
358
+ });
359
+
360
+ // Обновление линии cutoff при изменении значения
361
+ if (cutoffEl) {
362
+ cutoffEl.addEventListener('input', () => {
363
+ chart.update();
364
+ if (hr_chart) hr_chart.update();
365
+ });
366
+ }
367
+
368
+ // ── HR Fix график ────────────────────────────────────
369
+ let hr_chart = null, hr_syncedData = null, hr_pendingSync = null;
370
+ const hr_container = $$('#' + id('hr_container'));
371
+
372
+ if (hr_container) {
373
+ const hr_step_raw = $$(`#${mode}_hires_steps input[type=number]`);
374
+ const hr_step = hr_step_raw || step_ele;
375
+
376
+ const hr_canvas = document.createElement('canvas');
377
+ hr_canvas.width = 512; hr_canvas.height = 512;
378
+ const hr_plugins = createPlugins(hr_canvas, hr_step, cutoffEl);
379
+
380
+ hr_chart = new Chart(hr_canvas.getContext('2d'), {
381
+ type: 'scatter', data: createInitialData(),
382
+ options: createChartOption(), plugins: hr_plugins,
383
+ });
384
+
385
+ hr_chart.options.scales.y.ticks.callback = v => {
386
+ if (!showSigma || v < 1 || v > approxSigmas.length-1) return v;
387
+ const s = approxSigmas[Math.round(v)-1];
388
+ return s == null ? v : s < 1 ? s.toFixed(3) : s.toFixed(1);
389
+ };
390
+ hr_chart.options.plugins.tooltip.callbacks.title = ctxs => ctxs.map(c => {
391
+ const pos = c.parsed.y;
392
+ if (!showSigma) return `HR step ${c.parsed.x} → ${pos}`;
393
+ const s = approxSigmas[Math.round(pos)-1];
394
+ return `HR step ${c.parsed.x} → ${pos}${s != null ? ` (σ≈${s.toFixed(3)})` : ''}`;
395
+ });
396
+
397
+ hr_container.appendChild(hr_canvas);
398
+ updateSteps(hr_chart, +hr_step.value || +step_ele.value);
399
+
400
+ // Load persisted HR curve
401
+ const savedHr = localStorage.getItem(`tm_curve:${mode}:hr`);
402
+ if (savedHr) {
403
+ try {
404
+ const pts = JSON.parse(savedHr);
405
+ if (Array.isArray(pts) && pts.length >= 2) {
406
+ hr_chart.data.datasets[0].data = pts;
407
+ updateSteps(hr_chart, +hr_step.value || +step_ele.value);
408
+ hr_syncedData = JSON.stringify(hr_chart.data.datasets[0].data);
409
+ }
410
+ } catch(e) { /* ignore */ }
411
+ }
412
+
413
+ const hr_plugin = hr_plugins[0];
414
+ const _hoEnd = hr_plugin.endDrag.bind(hr_plugin);
415
+ hr_plugin.endDrag = (c,a)=>{ _hoEnd(c,a); triggerSync(c,'tm_hr'); };
416
+ const _hoAdd = hr_plugin.addPoint.bind(hr_plugin);
417
+ hr_plugin.addPoint = (c,a)=>{ _hoAdd(c,a); triggerSync(c,'tm_hr'); };
418
+ const _hoDel = hr_plugin.removePoint.bind(hr_plugin);
419
+ hr_plugin.removePoint = (c,a)=>{ _hoDel(c,a); triggerSync(c,'tm_hr'); };
420
+
421
+ hr_step.addEventListener('input', () => {
422
+ updateSteps(hr_chart, +hr_step.value || +step_ele.value);
423
+ triggerSync(hr_chart, 'tm_hr');
424
+ });
425
+
426
+ // Синхронизируем tension HR графика с основным
427
+ if (interpEl) interpEl.addEventListener('change', () => updateChartStyle(hr_chart));
428
+ }
429
+
430
+ // ── Generate button ─────────────────────────────────
431
+ let generating = false;
432
+ gradioApp().addEventListener('click', async e => {
433
+ if (e.target !== generate_button) return;
434
+ if (!enabled?.checked) return;
435
+ if (generating) { generating = false; return; }
436
+
437
+ const mainData = JSON.stringify(chart.data.datasets[0].data);
438
+ const hrData = hr_chart ? JSON.stringify(hr_chart.data.datasets[0].data) : null;
439
+ const needMain = mainData !== syncedData;
440
+ const needHR = hrData !== null && hrData !== hr_syncedData;
441
+
442
+ if (needMain || needHR) {
443
+ e.preventDefault(); e.stopPropagation();
444
+ await Promise.all([
445
+ needMain ? (pendingSync || lib.js2py(mode,'tm', mainData).then(()=>syncedData =mainData)) : Promise.resolve(),
446
+ needHR ? (hr_pendingSync || lib.js2py(mode,'tm_hr',hrData ).then(()=>hr_syncedData=hrData )) : Promise.resolve(),
447
+ ]);
448
+ generating = true; generate_button.click();
449
+ }
450
+ }, true);
451
+
452
+ // ── Нижняя панель ───────────────────────────────────
453
+ const PRESETS = {
454
+ 'Linear (Default)': n => [{x:1,y:1},{x:n,y:n}],
455
+ 'Detail Enhancer': n => [{x:1,y:1},{x:Math.max(2,Math.round(n*.25)),y:Math.round(n*.7)},{x:n,y:n}],
456
+ 'Composition Lock': n => [{x:1,y:1},{x:Math.round(n*.75),y:Math.max(2,Math.round(n*.25))},{x:n,y:n}],
457
+ 'Time Travel': n => [{x:1,y:1},{x:Math.round(n*.35),y:Math.round(n*.35)},{x:Math.round(n*.5),y:Math.max(1,Math.round(n*.15))},{x:Math.round(n*.65),y:Math.round(n*.35)},{x:n,y:n}],
458
+ 'Early Burst': n => [{x:1,y:1},{x:Math.round(n*.5),y:Math.round(n*.9)},{x:n,y:n}],
459
+ };
460
+
461
+ function makeBtn(label, title, onClick) {
462
+ const b = document.createElement('button');
463
+ b.textContent = label; b.type = 'button'; b.title = title || label;
464
+ b.style.cssText = 'padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px';
465
+ b.addEventListener('click', onClick); return b;
466
+ }
467
+
468
+ const bar = document.createElement('div');
469
+ bar.style.cssText = 'display:flex;gap:6px;align-items:center;margin:6px 0 0;flex-wrap:wrap;font-size:13px';
470
+
471
+ // ── Пресеты (built-in + custom из localStorage) ─────
472
+ function _storageKeys(prefix) {
473
+ const keys = [];
474
+ for (let i = 0; i < localStorage.length; i++) {
475
+ const k = localStorage.key(i);
476
+ if (k && k.startsWith(prefix)) keys.push(k);
477
+ }
478
+ return keys;
479
+ }
480
+
481
+ function _loadCustomPresets() {
482
+ const out = {};
483
+ for (const k of _storageKeys('tm_presets:')) {
484
+ const name = k.slice('tm_presets:'.length);
485
+ try {
486
+ const raw = JSON.parse(localStorage.getItem(k));
487
+ let pts, savedN;
488
+ if (Array.isArray(raw)) {
489
+ pts = raw;
490
+ savedN = Math.max(...pts.map(p => p.x));
491
+ } else if (raw && Array.isArray(raw.points) && raw.points.length >= 2) {
492
+ pts = raw.points;
493
+ savedN = raw.savedN || Math.max(...pts.map(p => p.x));
494
+ } else {
495
+ continue;
496
+ }
497
+ if (pts.length >= 2) {
498
+ out[name] = (n) => {
499
+ if (n === savedN || savedN <= 1) return pts;
500
+ const scale = (n - 1) / (savedN - 1);
501
+ return pts.map(p => ({
502
+ ...p,
503
+ x: Math.max(1, Math.min(n, Math.round(1 + (p.x - 1) * scale))),
504
+ }));
505
+ };
506
+ }
507
+ } catch(e) { /* ignore */ }
508
+ }
509
+ return out;
510
+ }
511
+
512
+ function _rebuildPresetDropdown() {
513
+ while (presetSel.firstChild) presetSel.removeChild(presetSel.firstChild);
514
+ // Built-in
515
+ Object.keys(PRESETS).forEach(n => {
516
+ const o = document.createElement('option'); o.value = n; o.textContent = n;
517
+ presetSel.appendChild(o);
518
+ });
519
+ // Custom
520
+ const custom = _loadCustomPresets();
521
+ const cKeys = Object.keys(custom);
522
+ if (cKeys.length) {
523
+ const grp = document.createElement('optgroup'); grp.label = '-- Saved --';
524
+ cKeys.forEach(n => {
525
+ const o = document.createElement('option'); o.value = n; o.textContent = n;
526
+ grp.appendChild(o);
527
+ });
528
+ presetSel.appendChild(grp);
529
+ }
530
+ // Store custom map on the select for the apply handler
531
+ presetSel._custom = custom;
532
+ }
533
+
534
+ const presetSel = document.createElement('select');
535
+ presetSel.style.cssText = 'padding:3px 6px;border-radius:4px;cursor:pointer;flex:1;min-width:120px';
536
+ _rebuildPresetDropdown();
537
+
538
+ const applyBtn = makeBtn('Apply', 'Применить пресет', () => {
539
+ const name = presetSel.value;
540
+ const fn = PRESETS[name] || presetSel._custom[name];
541
+ if (!fn) return;
542
+ const n = Math.max(2, +step_ele.value);
543
+ chart.data.datasets[0].data = fn(n);
544
+ updateSteps(chart, n);
545
+ saveState(chart); updateChartStyle(); triggerSync(chart);
546
+ });
547
+
548
+ const savePresetBtn = makeBtn('Save', 'Сохранить как пресет', () => {
549
+ const name = prompt('Название пресета:', '');
550
+ if (!name) return;
551
+ try {
552
+ const pts = chart.data.datasets[0].data.map(p => ({...p}));
553
+ localStorage.setItem(`tm_presets:${name}`, JSON.stringify({
554
+ savedN: Math.max(2, +step_ele.value),
555
+ points: pts,
556
+ }));
557
+ } catch(e) { alert('Не удалось сохранить пресет (превышен лимит localStorage).'); return; }
558
+ _rebuildPresetDropdown();
559
+ presetSel.value = name;
560
+ });
561
+
562
+ const delPresetBtn = makeBtn('Del', 'Удалить выбранный пресет', () => {
563
+ const name = presetSel.value;
564
+ if (!presetSel._custom[name]) return;
565
+ if (!confirm(`Удалить пресет "${name}"?`)) return;
566
+ localStorage.removeItem(`tm_presets:${name}`);
567
+ _rebuildPresetDropdown();
568
+ });
569
+
570
+ const resetBtn = makeBtn('Reset', 'Сбросить кривую к прямой', () => {
571
+ const n = Math.max(2, +step_ele.value);
572
+ chart.data.datasets[0].data = [{x:1,y:1},{x:n,y:n}];
573
+ saveState(chart); updateChartStyle(); triggerSync(chart);
574
+ });
575
+
576
+ // Undo / Redo
577
+ const undoBtn = makeBtn('Undo', 'Отменить (Ctrl+Z)', () => undo(chart));
578
+ const redoBtn = makeBtn('Redo', 'Повторить (Ctrl+Shift+Z)', () => redo(chart));
579
+
580
+ // Separator
581
+ const sep = () => { const s = document.createElement('span'); s.textContent='|'; s.style.opacity='.3'; return s; };
582
+
583
+ // Export
584
+ const exportBtn = makeBtn('Export', 'Сохранить кривую в JSON', () => {
585
+ const n = prompt('Название пресета:', 'my_curve') || 'my_curve';
586
+ const data = {
587
+ name: n, version: 1,
588
+ points: chart.data.datasets[0].data,
589
+ interpolation: getInterpMode(),
590
+ };
591
+ const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});
592
+ const url = URL.createObjectURL(blob);
593
+ const a = document.createElement('a');
594
+ a.href = url; a.download = `${n.replace(/\s+/g,'_')}.json`; a.click();
595
+ URL.revokeObjectURL(url);
596
+ });
597
+
598
+ // Import
599
+ const importBtn = makeBtn('Import', 'Загрузить кривую из JSON', () => {
600
+ const inp = document.createElement('input');
601
+ inp.type = 'file'; inp.accept = '.json';
602
+ inp.addEventListener('change', () => {
603
+ const file = inp.files[0]; if (!file) return;
604
+ const reader = new FileReader();
605
+ reader.onload = ev => {
606
+ try {
607
+ const d = JSON.parse(ev.target.result);
608
+ if (!Array.isArray(d.points)) return;
609
+ const n = Math.max(2, +step_ele.value);
610
+ const pts = d.points.filter(p => p.x>=1&&p.x<=n&&p.y>=1&&p.y<=n);
611
+ chart.data.datasets[0].data = pts;
612
+ updateSteps(chart, n);
613
+ saveState(chart); triggerSync(chart);
614
+ } catch(err) { console.error('[TimeMachine] Import error:', err); }
615
+ };
616
+ reader.readAsText(file);
617
+ });
618
+ inp.click();
619
+ });
620
+
621
+ // Show sigma
622
+ const sigmaBtn = makeBtn('Sigma', 'Показать σ-значения на оси Y (реальные через HTTP, fallback Karras)', () => {
623
+ showSigma = !showSigma;
624
+ sigmaBtn.textContent = showSigma ? 'Step' : 'Sigma';
625
+ chart.update(); if (hr_chart) hr_chart.update();
626
+ });
627
+
628
+ // ── Panel system ────────────────────────────────────────────────
629
+ let _panelDiv = null;
630
+ function _closePanel() { if (_panelDiv) { _panelDiv.remove(); _panelDiv = null; } }
631
+
632
+ function _openPanel(title, buildFn) {
633
+ _closePanel();
634
+ const d = document.createElement('div');
635
+ d.id = id('panel');
636
+ d.style.cssText = 'padding:8px;margin:4px 0;border:1px solid rgba(255,140,0,0.3);border-radius:6px;background:rgba(255,140,0,0.05);font-size:13px';
637
+ const h = document.createElement('div');
638
+ h.style.cssText = 'font-weight:bold;margin-bottom:6px';
639
+ h.textContent = title;
640
+ d.appendChild(h);
641
+ buildFn(d);
642
+ $('#' + id('container')).appendChild(d);
643
+ _panelDiv = d;
644
+ }
645
+
646
+ // ── Gen… (procedural curves) ────────────────────────────────────
647
+ const genBtn = makeBtn('Gen…', 'Сгенерировать кривую (синус / случайная)', () => {
648
+ let prevData = chart.data.datasets[0].data.slice();
649
+ function getN() { return Math.max(2, +step_ele.value); }
650
+
651
+ function _sine(freq, amp) {
652
+ const n = getN();
653
+ const pts = [{x:1,y:1}];
654
+ for (let x = 2; x < n; x++) {
655
+ const t = (x-1)/(n-1);
656
+ const mid = 1 + (n-1)*t;
657
+ const y = Math.round(mid + amp*(n-1)*Math.sin(2*Math.PI*freq*t));
658
+ pts.push({x, y: Math.max(1, Math.min(n, y))});
659
+ }
660
+ pts.push({x:n, y:n});
661
+ return pts;
662
+ }
663
+
664
+ function _walk(stepSz, seed) {
665
+ const n = getN();
666
+ let rng = seed || Date.now();
667
+ function rand() { rng ^= rng<<13; rng ^= rng>>17; rng ^= rng<<5; return (rng>>>0)/4294967296; }
668
+ const pts = [{x:1,y:1}];
669
+ let y = 1;
670
+ for (let x = 2; x < n; x++) {
671
+ y += (rand()-0.5)*2*stepSz;
672
+ y = Math.max(1, Math.min(n, Math.round(y)));
673
+ pts.push({x, y});
674
+ }
675
+ pts.push({x:n, y:n});
676
+ return pts;
677
+ }
678
+
679
+ _openPanel('Generate Curve', panel => {
680
+ const row1 = document.createElement('div');
681
+ row1.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:4px';
682
+ const sel = document.createElement('select');
683
+ sel.innerHTML = '<option>Sine</option><option>Random walk</option>';
684
+
685
+ const p1Label = document.createElement('span');
686
+ p1Label.style.cssText = 'width:55px;text-align:right';
687
+ const p1 = document.createElement('input');
688
+ p1.type = 'range'; p1.min = 0.5; p1.max = 10; p1.step = 0.5; p1.value = 2;
689
+
690
+ const p2Label = document.createElement('span');
691
+ p2Label.style.cssText = 'width:55px;text-align:right';
692
+ const p2 = document.createElement('input');
693
+ p2.type = 'range'; p2.min = 0; p2.max = 1; p2.step = 0.05; p2.value = 0.3;
694
+
695
+ function updateLabels() {
696
+ if (sel.value === 'Sine') {
697
+ p1Label.textContent = `Freq: ${p1.value}`;
698
+ p1.min = 0.5; p1.max = 10; p1.step = 0.5;
699
+ p2Label.textContent = `Amp: ${p2.value}`;
700
+ p2.min = 0; p2.max = 1; p2.step = 0.05;
701
+ } else {
702
+ p1Label.textContent = `Step: ${p1.value}`;
703
+ p1.min = 0.1; p1.max = 5; p1.step = 0.1;
704
+ p2Label.textContent = `Seed: ${p2.value}`;
705
+ p2.min = 0; p2.max = 9999; p2.step = 1;
706
+ }
707
+ }
708
+
709
+ function preview() {
710
+ const n = getN();
711
+ const pts = sel.value === 'Sine' ? _sine(+p1.value, +p2.value) : _walk(+p1.value, +p2.value);
712
+ chart.data.datasets[0].data = pts;
713
+ updateSteps(chart, n);
714
+ chart.update();
715
+ }
716
+
717
+ sel.addEventListener('change', () => { updateLabels(); preview(); });
718
+ p1.addEventListener('input', () => { updateLabels(); preview(); });
719
+ p2.addEventListener('input', () => { updateLabels(); preview(); });
720
+ updateLabels(); preview();
721
+
722
+ const apply = makeBtn('Apply', '', () => {
723
+ saveState(chart); updateChartStyle(); triggerSync(chart);
724
+ _closePanel();
725
+ });
726
+ const cancel = makeBtn('Cancel', '', () => {
727
+ const n = getN();
728
+ chart.data.datasets[0].data = prevData;
729
+ updateSteps(chart, n); chart.update();
730
+ _closePanel();
731
+ });
732
+ row1.append(sel, p1Label, p1, p2Label, p2, apply, cancel);
733
+ panel.appendChild(row1);
734
+ });
735
+ });
736
+
737
+ // ── Rasterize control points → dense array of length n ───────
738
+ const _WEIGHT = {
739
+ 'Ease In': t => t * t,
740
+ 'Ease Out': t => 1 - (1-t)*(1-t),
741
+ 'Ease In-Out': t => t*t*(3-2*t),
742
+ 'Cubic': t => t*t*t,
743
+ 'Exponential': t => Math.pow(2,t)-1,
744
+ 'Step': t => 0,
745
+ };
746
+
747
+ function _rasterize(pts, n) {
748
+ const sorted = [...pts].sort((a,b)=>a.x-b.x);
749
+ const out = [];
750
+ let si = 0;
751
+ for (let x = 1; x <= n; x++) {
752
+ while (si < sorted.length-2 && sorted[si+1].x <= x) si++;
753
+ const p0 = sorted[si], p1 = sorted[Math.min(si+1, sorted.length-1)];
754
+ let y;
755
+ if (p0.x === p1.x) { y = p0.y; }
756
+ else {
757
+ const mode = p0.mode || '';
758
+ const t = (x-p0.x)/(p1.x-p0.x);
759
+ if (mode === 'Step') { y = p0.y; }
760
+ else {
761
+ const w = _WEIGHT[mode] || (t => t);
762
+ y = p0.y + w(t) * (p1.y - p0.y);
763
+ }
764
+ }
765
+ out.push({x, y: Math.max(1, Math.min(n, Math.round(y)))});
766
+ }
767
+ return out;
768
+ }
769
+
770
+ // ── Blend… ──────────────────────────────────────────────────────
771
+ const blendBtn = makeBtn('Blend…', 'Смешать два пресета', () => {
772
+ const n = Math.max(2, +step_ele.value);
773
+ let prevData = chart.data.datasets[0].data.slice();
774
+ const allPresets = {...PRESETS, ...presetSel._custom};
775
+ const names = Object.keys(allPresets);
776
+
777
+ _openPanel('Blend Presets', panel => {
778
+ const row = document.createElement('div');
779
+ row.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap';
780
+
781
+ const sA = document.createElement('select');
782
+ const sB = document.createElement('select');
783
+ names.forEach(name => { sA.innerHTML += `<option>${name}</option>`; sB.innerHTML += `<option>${name}</option>`; });
784
+ if (names.length > 1) sB.selectedIndex = 1;
785
+
786
+ const sl = document.createElement('input');
787
+ sl.type = 'range'; sl.min = 0; sl.max = 100; sl.value = 50;
788
+ const slLabel = document.createElement('span');
789
+ slLabel.style.cssText = 'width:40px';
790
+
791
+ function _getPresetFn(name) { return PRESETS[name] || presetSel._custom[name]; }
792
+
793
+ function _blend() {
794
+ const fnA = _getPresetFn(sA.value);
795
+ const fnB = _getPresetFn(sB.value);
796
+ if (!fnA || !fnB) return null;
797
+ const cA = fnA(n), cB = fnB(n);
798
+ const denseA = _rasterize(cA, n);
799
+ const denseB = _rasterize(cB, n);
800
+ const t = +sl.value / 100;
801
+ slLabel.textContent = `${sl.value}%`;
802
+ return denseA.map((pt, i) => ({
803
+ x: pt.x,
804
+ y: Math.round(pt.y*(1-t) + denseB[i].y*t),
805
+ }));
806
+ }
807
+
808
+ function preview() { const pts = _blend(); if (pts) { chart.data.datasets[0].data = pts; updateSteps(chart, n); chart.update(); } }
809
+
810
+ sA.addEventListener('change', preview);
811
+ sB.addEventListener('change', preview);
812
+ sl.addEventListener('input', preview);
813
+ preview();
814
+
815
+ const apply = makeBtn('Apply', '', () => { saveState(chart); updateChartStyle(); triggerSync(chart); _closePanel(); });
816
+ const cancel = makeBtn('Cancel', '', () => { chart.data.datasets[0].data = prevData; updateSteps(chart, n); chart.update(); _closePanel(); });
817
+
818
+ row.append(sA, sB, sl, slLabel, apply, cancel);
819
+ panel.appendChild(row);
820
+ });
821
+ });
822
+
823
+ bar.append(presetSel, applyBtn, savePresetBtn, delPresetBtn, resetBtn, sep(), undoBtn, redoBtn, sep(), exportBtn, importBtn, sep(), sigmaBtn, genBtn, blendBtn);
824
+ $('#' + id('container')).appendChild(bar);
825
+
826
+ // ── One-shot: автосброс Enabled после генерации ────────────────
827
+ // Паттерн из script.js A1111: следим за interruptBtn.style.display
828
+ if (oneShotEl) {
829
+ const interruptBtn = gradioApp().querySelector(`#${mode}_interrupt`);
830
+ if (interruptBtn) {
831
+ let wasGen = false;
832
+ new MutationObserver(() => {
833
+ const isGen = interruptBtn.style.display === 'block';
834
+ if (isGen && !wasGen) {
835
+ wasGen = true;
836
+ } else if (!isGen && wasGen) {
837
+ wasGen = false;
838
+ if (oneShotEl.checked && enabled?.checked) {
839
+ enabled.checked = false;
840
+ enabled.dispatchEvent(new Event('change', { bubbles: true }));
841
+ }
842
+ }
843
+ }).observe(interruptBtn, { attributes: true, attributeFilter: ['style'] });
844
+ }
845
+ }
846
+ }
847
+
848
+ // ── Общие функции ────────────────────────────────────────────────
849
+
850
+ function createInitialData() {
851
+ return { datasets: [{
852
+ type:'line', showLine:true, tension:0,
853
+ backgroundColor:'rgba(255,140,0,.6)', borderColor:'rgba(255,140,0,.6)',
854
+ borderWidth:2, borderCapStyle:'round', borderJoinStyle:'round',
855
+ borderDash:[], borderDashOffset:0,
856
+ pointBorderColor:'rgba(255,140,0,.6)', pointBackgroundColor:'rgba(255,140,0,.6)',
857
+ pointBorderWidth:1, pointHoverBorderWidth:10, pointRadius:5, pointHitRadius:10,
858
+ fill:false, data:[],
859
+ }]};
860
+ }
861
+
862
+ function createChartOption() {
863
+ return {
864
+ responsive:false,
865
+ events:['mouseup','mousedown','mousemove','mouseout','click','touchstart','touchmove'],
866
+ scales:{
867
+ x:{type:'linear',display:true,title:{display:true,text:'timesteps'},ticks:{major:{enabled:true}},min:0,max:100,stepSize:10},
868
+ y:{type:'linear',display:true,title:{display:true,text:'actual timesteps'},ticks:{major:{enabled:true}},min:0,max:100,stepSize:10},
869
+ },
870
+ chartArea:{backgroundColor:'rgba(255,255,255,1)'},
871
+ animation:{duration:100},
872
+ plugins:{legend:{display:false},tooltip:{callbacks:{
873
+ title:ctxs=>ctxs.map(c=>`${c.parsed.x} → ${c.parsed.y}`), label:()=>'',
874
+ }}},
875
+ };
876
+ }
877
+
878
+ function createPlugins(canvas, step_ele, cutoffEl) {
879
+ const plugins = [{
880
+ id:'dragpoint',
881
+ beforeEvent(chart, args) {
882
+ const t = args.event.type;
883
+ if (t === 'mousedown') {
884
+ const btn = args.event.native.button;
885
+ if (btn===0) this.startDrag(chart, args);
886
+ else if (btn===2) this.removePoint(chart, args);
887
+ } else if (t==='mouseup'||t==='mouseout') {
888
+ if (this.dragctx.item) this.endDrag(chart, args);
889
+ } else if (t==='mousemove') {
890
+ if (this.dragctx.item) this.onDrag(chart, args);
891
+ }
892
+ },
893
+ afterDraw(chart) {
894
+ const ctx = chart.ctx; ctx.save();
895
+ // Cutoff line
896
+ const cs = cutoffEl ? +cutoffEl.value : 0;
897
+ if (cs > 0) {
898
+ const xa = chart.scales.x, ya = chart.scales.y, x = xa.getPixelForValue(cs);
899
+ ctx.beginPath(); ctx.setLineDash([6, 4]); ctx.strokeStyle = 'rgba(220,50,50,0.6)';
900
+ ctx.lineWidth = 2; ctx.moveTo(x, ya.top); ctx.lineTo(x, ya.bottom); ctx.stroke();
901
+ }
902
+ // Per-segment mode indicators
903
+ const data = chart.data.datasets[0].data;
904
+ for (let i = 0; i < data.length - 1; i++) {
905
+ const mode = data[i].mode;
906
+ if (!mode) continue;
907
+ const x0 = chart.scales.x.getPixelForValue(data[i].x);
908
+ const y0 = chart.scales.y.getPixelForValue(data[i].y);
909
+ const x1 = chart.scales.x.getPixelForValue(data[i+1].x);
910
+ const y1 = chart.scales.y.getPixelForValue(data[i+1].y);
911
+ const mx = (x0+x1)/2, my = (y0+y1)/2;
912
+ ctx.beginPath(); ctx.arc(mx, my, 5, 0, 2*Math.PI);
913
+ ctx.fillStyle = '#ff8c00'; ctx.fill();
914
+ ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
915
+ ctx.fillStyle = '#fff';
916
+ ctx.font = 'bold 8px sans-serif';
917
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
918
+ ctx.fillText(mode[0], mx, my);
919
+ }
920
+ ctx.restore();
921
+ },
922
+ dragctx:{item:null, max_steps:()=>Math.max(1,+step_ele.value)},
923
+ lastRemoved:false,
924
+ startDrag(chart,args){const item=this.getItem(chart,args);if(item)this.dragctx.item=item;else this.addPoint(chart,args);},
925
+ onDrag(chart,args){const y=this.getY(chart,args);const{datasetIndex,index}=this.dragctx.item;const item=chart.data.datasets[datasetIndex].data[index];if(item.y!==y){item.y=y;chart.update();}},
926
+ endDrag(chart){this.dragctx.item=null;},
927
+ addPoint(chart,args){const x=this.getX(chart,args),y=this.getY(chart,args);const found=chart.data.datasets[0].data.find(v=>v.x===x);if(found){if(found.y!==y){found.y=y;chart.update();}}else this.addItem(chart,{x,y});},
928
+ removePoint(chart,args){const item_=this.getItem(chart,args);if(item_){const{datasetIndex,index}=item_;const item=chart.data.datasets[datasetIndex].data[index];if(1<item.x&&item.x<this.dragctx.max_steps()){this.lastRemoved=true;chart.data.datasets[datasetIndex].data.splice(index,1);chart.update();}}},
929
+ getX(chart,args){const xx=chart.scales.x.getValueForPixel(args.event.native.clientX-chart.canvas.getBoundingClientRect().left);return Math.max(1,Math.min(Math.round(xx),this.dragctx.max_steps()));},
930
+ getY(chart,args){const yy=chart.scales.y.getValueForPixel(args.event.native.clientY-chart.canvas.getBoundingClientRect().top);return Math.max(1,Math.min(Math.round(yy),this.dragctx.max_steps()));},
931
+ getItem(chart,args){const items=chart.getElementsAtEventForMode(args.event,'nearest',{intersect:true},false);return items.length===0?null:items[0];},
932
+ addItem(chart,xy,donotupdate){chart.data.datasets[0].data.push(xy);chart.data.datasets[0].data.sort((a,b)=>a.x-b.x);if(!donotupdate)chart.update();},
933
+ }];
934
+
935
+ return plugins;
936
+ }
937
+
938
+ function updateSteps(chart, max_steps) {
939
+ max_steps = Math.max(2, max_steps);
940
+ chart.options.scales.x.max = max_steps+1;
941
+ chart.options.scales.y.max = max_steps+1;
942
+ const data = chart.data.datasets[0].data;
943
+ const new_data = [];
944
+ for (const pt of data) if(1<=pt.x&&pt.x<=max_steps) new_data.push({...pt, y:Math.min(pt.y,max_steps)});
945
+ if (!new_data.find(c=>c.x===1)) new_data.unshift({x:1, y:1});
946
+ if (!new_data.find(c=>c.x===max_steps)) new_data.push ({x:max_steps,y:max_steps});
947
+ chart.data.datasets[0].data = new_data;
948
+ chart.update();
949
+ }
950
+
951
+ })('TimeMachine');
sd-webui-timemachine-fixed/scripts/timemachine.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import bisect
6
+ from typing import Any, List, Callable
7
+
8
+ import gradio as gr
9
+
10
+ from modules.processing import StableDiffusionProcessing
11
+ from modules import scripts, extensions, script_callbacks
12
+ from fastapi.responses import JSONResponse
13
+
14
+ from scripts.timemachinelib import sampler as sampler_utils
15
+ from scripts.timemachinelib.xyz import init_xyz
16
+
17
+ NAME = 'TimeMachine'
18
+ _UNSUPPORTED = sampler_utils.UNSUPPORTED_SAMPLERS
19
+
20
+
21
+ class Script(scripts.Script):
22
+
23
+ def title(self):
24
+ return NAME
25
+
26
+ def show(self, is_img2img):
27
+ return scripts.AlwaysVisible
28
+
29
+ def ui(self, is_img2img):
30
+ ext = _get_self_extension()
31
+ if ext is not None and not is_img2img:
32
+ js_ = [f'{x.path}?{os.path.getmtime(x.path)}'
33
+ for x in ext.list_files('javascript/modules', '.js')]
34
+ js_.insert(0, ext.path)
35
+ gr.HTML(value='\n'.join(js_), elem_id=f'{NAME.lower()}-js_modules')
36
+
37
+ mode = 'img2img' if is_img2img else 'txt2img'
38
+ id_ = lambda x: f'{NAME.lower()}-{mode}-{x}'
39
+ js_ = lambda s: f'globalThis["{id_(s)}"]'
40
+
41
+ with gr.Accordion(NAME, open=False, elem_id=id_('accordion')):
42
+ with gr.Row():
43
+ enabled = gr.Checkbox(label='Enabled', value=False, elem_id=id_('enabled'))
44
+ scheduler = gr.Dropdown(
45
+ label='Scheduler', choices=sampler_utils.get_scheduler_choices(),
46
+ value='Use sampler default', elem_id=id_('scheduler'))
47
+ interp_mode = gr.Dropdown(
48
+ label='Interpolation', choices=['Linear', 'Smooth (spline)', 'Step', 'Monotone', 'Ease In', 'Ease Out', 'Ease In-Out', 'Cubic', 'Exponential'],
49
+ value='Linear', elem_id=id_('interpolation'))
50
+
51
+ with gr.Row():
52
+ cutoff_steps = gr.Number(
53
+ label='Cutoff step (0=off)', value=0, minimum=0, maximum=150,
54
+ step=1, elem_id=id_('cutoff'))
55
+ hr_cutoff_cb = gr.Checkbox(
56
+ label='HR Cutoff', value=True, elem_id=id_('hr_cutoff'))
57
+ one_shot = gr.Checkbox(
58
+ label='One-shot', value=False, elem_id=id_('one_shot'))
59
+
60
+ gr.HTML(elem_id=id_('container'))
61
+
62
+ with gr.Accordion('HR Fix Curve (txt2img only)', open=False,
63
+ elem_id=id_('hr_accordion')):
64
+ hr_enabled = gr.Checkbox(
65
+ label='Use separate curve for HR pass', value=False,
66
+ elem_id=id_('hr_enabled'))
67
+ gr.HTML(elem_id=id_('hr_container'))
68
+
69
+ with gr.Group(visible=False):
70
+ sink = gr.HTML(value='')
71
+ tm = _js2py('tm', id_, js_, sink)
72
+ hr_sink = gr.HTML(value='')
73
+ tm_hr = _js2py('tm_hr', id_, js_, hr_sink)
74
+
75
+ return [enabled, scheduler, interp_mode, tm, hr_enabled, tm_hr, cutoff_steps, hr_cutoff_cb, one_shot]
76
+
77
+ def process_batch(
78
+ self,
79
+ p: StableDiffusionProcessing,
80
+ enabled: bool,
81
+ scheduler: str,
82
+ interp_mode: str,
83
+ tm: str,
84
+ hr_enabled: bool,
85
+ tm_hr: str,
86
+ cutoff_steps: int = 0,
87
+ hr_cutoff_enabled: bool = True,
88
+ one_shot: bool = False,
89
+ **kwargs,
90
+ ):
91
+ if not enabled:
92
+ return
93
+ if not tm or not tm.strip():
94
+ return
95
+ if p.sampler_name in _UNSUPPORTED:
96
+ msg = (f'⚠ {p.sampler_name!r} несовместим: '
97
+ f'принимает sigma_min/max/n, не массив sigmas. '
98
+ f'Используйте: Euler, DPM++ 2M, DPM++ SDE, Heun, LMS и др.')
99
+ print(f'\n[TimeMachine] {msg}\n')
100
+ gr.Warning(f'[TimeMachine] {msg}')
101
+ return
102
+
103
+ vs = _parse_points(tm)
104
+ if vs is None:
105
+ return
106
+
107
+ if len(vs) < 2 or 1 not in vs or p.steps not in vs:
108
+ points = [{'x': k, 'y': v['y'], 'mode': v.get('mode')} for k, v in sorted(vs.items())]
109
+ msg = (f'Кривая должна содержать точки x=1 и x={p.steps} '
110
+ f'(текущие x: {[p["x"] for p in points]})')
111
+ print(f'[TimeMachine] {msg}')
112
+ gr.Warning(f'[TimeMachine] {msg}')
113
+ return
114
+
115
+ steps_float = _interpolate(vs, p.steps, interp_mode)
116
+ if len(steps_float) != p.steps:
117
+ return
118
+
119
+ cs = int(cutoff_steps)
120
+ steps_float = _apply_cutoff(steps_float, cs, p.steps)
121
+
122
+ if scheduler == 'Use sampler default':
123
+ sched_name = sampler_utils.get_default_scheduler_for(p.sampler_name)
124
+ else:
125
+ from modules import sd_schedulers
126
+ sched_name = next(
127
+ (s.name for s in sd_schedulers.schedulers if s.label == scheduler),
128
+ 'karras')
129
+
130
+ override_main = sampler_utils.build_sigma_override(steps_float, sched_name)
131
+ override_hr = _build_hr_override(p, hr_enabled, tm_hr, interp_mode, sched_name, cs, hr_cutoff_enabled)
132
+
133
+ if override_hr is not None:
134
+ def combined(n: int):
135
+ return override_hr(n) if getattr(p, 'is_hr_pass', False) else override_main(n)
136
+ p.sampler_noise_scheduler_override = combined
137
+ else:
138
+ p.sampler_noise_scheduler_override = override_main
139
+
140
+ p._timemachine_override_set = True
141
+ p.extra_generation_params.update({
142
+ f'{NAME} Enabled': True,
143
+ f'{NAME} Interpolation': interp_mode,
144
+ f'{NAME} Steps': [round(t, 3) for t in steps_float],
145
+ f'{NAME} Scheduler': sched_name,
146
+ f'{NAME} Cutoff': cs,
147
+ f'{NAME} One-shot': one_shot,
148
+ })
149
+
150
+ def postprocess_batch(self, p, *args, **kwargs):
151
+ if getattr(p, '_timemachine_override_set', False):
152
+ p.sampler_noise_scheduler_override = None
153
+ p._timemachine_override_set = False
154
+
155
+
156
+ # ── Интерполяция ──────────────────────────────────────────
157
+
158
+ def _sliding_pairs(xs):
159
+ for i in range(len(xs) - 1):
160
+ yield xs[i], xs[i + 1]
161
+
162
+
163
+ def _find_segment(xs: list, step: int) -> int:
164
+ i = bisect.bisect_right(xs, step) - 1
165
+ return max(0, min(i, len(xs) - 2))
166
+
167
+
168
+ _WEIGHT_FUNCS: dict[str, Callable[[float], float]] = {
169
+ 'Ease In': lambda t: t * t,
170
+ 'Ease Out': lambda t: 1.0 - (1.0 - t) * (1.0 - t),
171
+ 'Ease In-Out': lambda t: t * t * (3.0 - 2.0 * t),
172
+ 'Cubic': lambda t: t ** 3,
173
+ 'Exponential': lambda t: 2.0 ** t - 1.0,
174
+ }
175
+ _GLOBAL_MODES = frozenset({'Smooth (spline)', 'Step', 'Monotone'})
176
+
177
+
178
+ def _interpolate_segments(vs: dict, n: int, default_mode: str = 'Linear', ignore_per_segment: bool = False) -> List[float]:
179
+ default_weight_fn = _WEIGHT_FUNCS.get(default_mode, lambda t: t)
180
+ steps: List[float] = []
181
+ for min_x, max_x in _sliding_pairs(sorted(vs.keys())):
182
+ min_y, max_y = float(vs[min_x]['y']), float(vs[max_x]['y'])
183
+ seg_mode = default_mode if ignore_per_segment else (vs[min_x].get('mode') or default_mode)
184
+ if seg_mode == 'Step':
185
+ for step in range(min_x, max_x):
186
+ steps.append(min_y)
187
+ else:
188
+ wfn = _WEIGHT_FUNCS.get(seg_mode, default_weight_fn)
189
+ for step in range(min_x, max_x):
190
+ t = (step - min_x) / (max_x - min_x)
191
+ steps.append(min_y + wfn(t) * (max_y - min_y))
192
+ steps.append(float(vs[n]['y']))
193
+ return steps
194
+
195
+
196
+ def _interpolate(vs: dict, n: int, mode: str) -> List[float]:
197
+ if mode == 'Smooth (spline)': return _compute_remapped_steps_spline(vs, n)
198
+ if mode == 'Step': return _compute_remapped_steps_step(vs, n)
199
+ if mode == 'Monotone': return _compute_remapped_steps_monotone(vs, n)
200
+ return _interpolate_segments(vs, n, mode)
201
+
202
+
203
+ def _compute_remapped_steps_step(vs: dict, n: int) -> List[float]:
204
+ return _interpolate_segments(vs, n, default_mode='Step', ignore_per_segment=True)
205
+
206
+
207
+ def _compute_remapped_steps_monotone(vs: dict, n: int) -> List[float]:
208
+ xs = sorted(vs.keys())
209
+ ys = [float(vs[x]['y']) for x in xs]
210
+ k = len(xs)
211
+
212
+ if k == 2:
213
+ return _interpolate_segments(vs, n, ignore_per_segment=True)
214
+
215
+ h = [xs[i+1] - xs[i] for i in range(k-1)]
216
+ delta = [(ys[i+1]-ys[i]) / h[i] for i in range(k-1)]
217
+
218
+ m = [0.0] * k
219
+ m[0] = delta[0]
220
+ m[k-1] = delta[k-2]
221
+ for i in range(1, k-1):
222
+ p = (delta[i-1]*h[i] + delta[i]*h[i-1]) / (h[i-1]+h[i])
223
+ if delta[i-1]*delta[i] <= 0:
224
+ m[i] = 0.0
225
+ else:
226
+ sign = 1.0 if p >= 0 else -1.0
227
+ m[i] = sign * min(abs(p), 2*abs(delta[i-1]), 2*abs(delta[i]))
228
+
229
+ steps: List[float] = []
230
+ for step in range(1, n+1):
231
+ seg = _find_segment(xs, step)
232
+ x0, x1 = xs[seg], xs[seg+1]
233
+ h_s = x1 - x0
234
+ t = max(0.0, min(1.0, (step-x0)/h_s if h_s>0 else 1.0))
235
+ t2, t3 = t*t, t*t*t
236
+ y = ((2*t3-3*t2+1)*ys[seg] + (t3-2*t2+t)*h_s*m[seg]
237
+ + (-2*t3+3*t2)*ys[seg+1] + (t3-t2)*h_s*m[seg+1])
238
+ steps.append(max(1.0, min(float(n), y)))
239
+ return steps
240
+
241
+
242
+ def _catmull_rom(t: float, p0: float, p1: float, p2: float, p3: float) -> float:
243
+ t2 = t * t; t3 = t2 * t
244
+ return 0.5 * (2*p1 + (-p0+p2)*t + (2*p0-5*p1+4*p2-p3)*t2 + (-p0+3*p1-3*p2+p3)*t3)
245
+
246
+
247
+ def _compute_remapped_steps_spline(vs: dict, n: int) -> List[float]:
248
+ xs = sorted(vs.keys()); ys = [float(vs[x]['y']) for x in xs]; k = len(xs)
249
+ y_ext = [2*ys[0]-ys[1]] + ys + [2*ys[-1]-ys[-2]]
250
+ steps: List[float] = []
251
+ for step in range(1, n+1):
252
+ seg = _find_segment(xs, step)
253
+ x0, x1 = xs[seg], xs[seg+1]
254
+ t = max(0., min(1., (step-x0)/(x1-x0) if x1>x0 else 1.))
255
+ i = seg+1
256
+ y = _catmull_rom(t, y_ext[i-1], y_ext[i], y_ext[i+1], y_ext[i+2])
257
+ steps.append(max(1., min(float(n), y)))
258
+ return steps
259
+
260
+
261
+ def _apply_cutoff(sf: list[float], cs: int, total: int, blend: int = 4) -> list[float]:
262
+ """Плавный cutoff: W=blend шагов смешиваем кривую → identity, дальше чистая identity."""
263
+ if cs <= 0 or cs >= total:
264
+ return sf
265
+ out = sf[:]
266
+ blend_end = min(cs + blend, total)
267
+ blend_steps = blend_end - cs
268
+ for i in range(cs, blend_end):
269
+ alpha = (i - cs + 1) / blend_steps if blend_steps > 0 else 1.0
270
+ out[i] = out[i] * (1.0 - alpha) + float(i + 1) * alpha
271
+ for i in range(blend_end, total):
272
+ out[i] = float(i + 1)
273
+ return out
274
+
275
+
276
+ def _build_hr_override(p, hr_enabled: bool, tm_hr: str, interp_mode: str, sched_name: str,
277
+ cutoff_steps: int = 0, hr_cutoff_enabled: bool = True):
278
+ """Строит override для HR-прохода если все условия выполнены."""
279
+ if not hr_enabled:
280
+ return None
281
+ if not getattr(p, 'enable_hr', False):
282
+ return None
283
+ if not tm_hr or not tm_hr.strip():
284
+ return None
285
+
286
+ hr_steps = getattr(p, 'hr_second_pass_steps', 0)
287
+ if hr_steps <= 0:
288
+ hr_steps = p.steps
289
+
290
+ vs_hr = _parse_points(tm_hr)
291
+ if vs_hr is None:
292
+ return None
293
+
294
+ if len(vs_hr) < 2 or 1 not in vs_hr or hr_steps not in vs_hr:
295
+ msg = (f'HR кривая должна содержать x=1 и x={hr_steps} '
296
+ f'(x: {sorted(vs_hr.keys())})')
297
+ print(f'[TimeMachine] {msg}')
298
+ gr.Warning(f'[TimeMachine] {msg}')
299
+ return None
300
+
301
+ sf = _interpolate(vs_hr, hr_steps, interp_mode)
302
+ if len(sf) != hr_steps:
303
+ return None
304
+
305
+ if hr_cutoff_enabled and cutoff_steps > 0:
306
+ if hr_steps <= cutoff_steps:
307
+ hr_cs = max(1, round(cutoff_steps * hr_steps / p.steps))
308
+ else:
309
+ hr_cs = cutoff_steps
310
+ sf = _apply_cutoff(sf, hr_cs, hr_steps)
311
+
312
+ return sampler_utils.build_sigma_override(sf, sched_name)
313
+
314
+
315
+ # ── Вспомогательные ──────────────────────────────────────
316
+
317
+ def _parse_points(json_str: str) -> dict[int, dict] | None:
318
+ try:
319
+ pts = json.loads(json_str)
320
+ except json.JSONDecodeError:
321
+ return None
322
+ if not isinstance(pts, list):
323
+ return None
324
+ try:
325
+ result: dict[int, dict] = {}
326
+ for v in pts:
327
+ if isinstance(v, dict) and 'x' in v and 'y' in v:
328
+ x = int(v['x'])
329
+ result[x] = {
330
+ 'y': int(v['y']),
331
+ 'mode': v.get('mode') or None,
332
+ }
333
+ return result
334
+ except (KeyError, TypeError, ValueError):
335
+ return None
336
+
337
+
338
+ def _get_self_extension():
339
+ self_path = os.path.abspath(__file__)
340
+ for ext in extensions.active():
341
+ if self_path.startswith(os.path.abspath(ext.path) + os.sep):
342
+ return ext
343
+
344
+
345
+ def _js2py(name: str, id_: Callable, js_: Callable, sink: Any):
346
+ v_set = gr.Button(elem_id=id_(f'{name}_set'))
347
+ v = gr.Textbox(elem_id=id_(name))
348
+ v_sink = gr.Textbox()
349
+ v_set.click(fn=None, _js=js_(name), outputs=[v, v_sink])
350
+ v_sink.change(fn=None, _js=js_(f'{name}_after'), outputs=[sink])
351
+ return v
352
+
353
+
354
+ init_xyz(Script, NAME)
355
+ sampler_utils.register_ni_samplers()
356
+
357
+
358
+ # ── FastAPI: real sigmas for JS ──────────────────────────────
359
+
360
+ def _on_app_started(demo, app):
361
+ @app.get("/timemachine/sigmas")
362
+ def get_sigmas(scheduler: str = 'karras', steps: int = 20, sampler: str | None = None):
363
+ try:
364
+ if scheduler == 'Use sampler default' and sampler:
365
+ scheduler = sampler_utils.get_default_scheduler_for(sampler)
366
+ sigmas = sampler_utils.compute_sigmas(steps, scheduler)
367
+ return {"sigmas": sigmas.tolist()}
368
+ except Exception as e:
369
+ print(f'[TimeMachine] sigmas API error: {e}')
370
+ return JSONResponse(content={"sigmas": []}, status_code=500)
371
+
372
+ script_callbacks.on_app_started(_on_app_started)
sd-webui-timemachine-fixed/scripts/timemachinelib/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .sampler import (
2
+ build_sigma_override,
3
+ compute_sigmas,
4
+ get_default_scheduler_for,
5
+ get_scheduler_choices,
6
+ register_ni_samplers,
7
+ UNSUPPORTED_SAMPLERS,
8
+ )
9
+ from .xyz import init_xyz
10
+
11
+ __all__ = [
12
+ 'build_sigma_override', 'compute_sigmas',
13
+ 'get_default_scheduler_for', 'get_scheduler_choices',
14
+ 'register_ni_samplers', 'UNSUPPORTED_SAMPLERS',
15
+ 'init_xyz',
16
+ ]
sd-webui-timemachine-fixed/scripts/timemachinelib/sampler.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # timemachinelib/sampler.py — v3.1 (bugfix)
2
+ #
3
+ # Исправления:
4
+ # - scheduler.need_inner_model → getattr (AttributeError на нестандартных планировщиках)
5
+ # - scheduler.default_rho → getattr
6
+ # - opts.rho/sigma_min/sigma_max/use_old_karras → getattr (отсутствуют в ряде версий)
7
+ # - Удалён неиспользуемый Optional
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import torch
14
+ from modules import sd_schedulers, devices
15
+ from modules.shared import opts
16
+
17
+
18
+ def _build_model_wrap():
19
+ """k-diffusion denoiser wrapper. Поддерживает SD1/SD2/SDXL/SD3."""
20
+ from modules import shared
21
+ import k_diffusion.external
22
+
23
+ sd_model = shared.sd_model
24
+ if hasattr(sd_model, 'create_denoiser'):
25
+ return sd_model.create_denoiser()
26
+
27
+ param = getattr(sd_model, 'parameterization', 'eps')
28
+ cls = (k_diffusion.external.CompVisVDenoiser
29
+ if param == 'v' else k_diffusion.external.CompVisDenoiser)
30
+ quantize = getattr(opts, 'enable_quantization', False)
31
+ return cls(sd_model, quantize=quantize)
32
+
33
+
34
+ def compute_sigmas(n: int, scheduler_name: str, model_wrap=None) -> torch.Tensor:
35
+ """Генерирует n+1 сигм (убывающие, последний = 0)."""
36
+ if model_wrap is None:
37
+ model_wrap = _build_model_wrap()
38
+
39
+ m_sigma_min = model_wrap.sigmas[0].item()
40
+ m_sigma_max = model_wrap.sigmas[-1].item()
41
+
42
+ # BUG-FIX: getattr вместо прямого обращения — атрибуты могут отсутствовать
43
+ if getattr(opts, 'use_old_karras_scheduler_sigmas', False):
44
+ sigma_min, sigma_max = 0.1, 10.0
45
+ else:
46
+ raw_min = getattr(opts, 'sigma_min', 0)
47
+ raw_max = getattr(opts, 'sigma_max', 0)
48
+ sigma_min = raw_min if raw_min != 0 else m_sigma_min
49
+ sigma_max = raw_max if raw_max != 0 else m_sigma_max
50
+
51
+ scheduler = sd_schedulers.schedulers_map.get(scheduler_name)
52
+ if scheduler is None or scheduler.function is None:
53
+ return model_wrap.get_sigmas(n).cpu()
54
+
55
+ kwargs: dict = {'sigma_min': sigma_min, 'sigma_max': sigma_max, 'device': devices.cpu}
56
+
57
+ # BUG-FIX: getattr с fallback — у нестандартных планировщиков атрибут может отсутствовать
58
+ if getattr(scheduler, 'need_inner_model', False):
59
+ kwargs['inner_model'] = model_wrap
60
+
61
+ default_rho = getattr(scheduler, 'default_rho', -1)
62
+ rho = getattr(opts, 'rho', 0)
63
+ if default_rho != -1 and rho != 0 and rho != default_rho:
64
+ kwargs['rho'] = rho
65
+
66
+ return scheduler.function(n=n, **kwargs).cpu()
67
+
68
+
69
+ def _lerp_sigma(full_sigmas: torch.Tensor, t: float) -> torch.Tensor:
70
+ """
71
+ Линейная интерполяция сигмы для дробной позиции t ∈ [1.0, max_idx].
72
+ t=1.0 → full_sigmas[0] (макс. шум)
73
+ t=max_idx → full_sigmas[max_idx-1] (мин. ненулевой шум)
74
+ """
75
+ n = len(full_sigmas) - 1 # кол-во ненулевых позиций
76
+ lo = max(0, int(math.floor(t)) - 1) # 0-based нижняя граница
77
+ hi = min(lo + 1, n - 1) # 0-based верхняя (не выходим за 0-элемент)
78
+ frac = t - math.floor(t)
79
+ return full_sigmas[lo] * (1.0 - frac) + full_sigmas[hi] * frac
80
+
81
+
82
+ def build_sigma_override(steps_float: list[float], scheduler_name: str):
83
+ """
84
+ Возвращает функцию для p.sampler_noise_scheduler_override.
85
+
86
+ steps_float — float позиции (1-based) в sigma-расписании, одна на каждый UI-шаг.
87
+ scheduler_name — имя планировщика ('karras', 'exponential', ...)
88
+ """
89
+ max_idx = math.ceil(max(steps_float))
90
+
91
+ def override(n: int) -> torch.Tensor:
92
+ """n = p.steps (или +1 при discard_next_to_last_sigma)."""
93
+ model_wrap = _build_model_wrap()
94
+ full_sigmas = compute_sigmas(max_idx, scheduler_name, model_wrap)
95
+
96
+ selected = [_lerp_sigma(full_sigmas, t) for t in steps_float]
97
+
98
+ delta = n - len(selected)
99
+ if delta > 0:
100
+ last = selected[-1]
101
+ min_sig = full_sigmas[-2] # минимальная ненулевая
102
+ for i in range(1, delta + 1):
103
+ alpha = i / (delta + 1)
104
+ selected.append(last * (1.0 - alpha) + min_sig * alpha)
105
+ elif delta < 0:
106
+ selected = selected[:n]
107
+
108
+ return torch.cat([torch.stack(selected), full_sigmas[-1:]])
109
+
110
+ return override
111
+
112
+
113
+ def get_default_scheduler_for(sampler_name: str) -> str:
114
+ """Возвращает scheduler по умолчанию для данного семплера."""
115
+ from modules import sd_samplers
116
+ config = sd_samplers.find_sampler_config(sampler_name)
117
+ if config:
118
+ return config.options.get('scheduler', 'karras')
119
+ return 'karras'
120
+
121
+
122
+ def get_scheduler_choices() -> list[str]:
123
+ """Список планировщиков для UI-дропдауна."""
124
+ skip = {'automatic', 'Automatic'}
125
+ return ['Use sampler default'] + [
126
+ s.label
127
+ for s in sd_schedulers.schedulers
128
+ if s.name not in skip and s.label not in skip and s.function is not None
129
+ ]
130
+
131
+
132
+ UNSUPPORTED_SAMPLERS = {'DPM fast', 'DPM adaptive'}
133
+
134
+
135
+ # ═══════════════════════════════════════════════════════════════
136
+ # Noise Injection (NI) семплеры
137
+ #
138
+ # Физическая идея: когда σ растёт между шагами (Time Travel),
139
+ # латент находится «слишком чистым» для текущего уровня шума.
140
+ # Правильное решение — добавить шум: x += √(σ²_{k+1} − σ²_k) · ε
141
+ #
142
+ # Кастомные циклы обязательны: стандартные сэмплеры k-diffusion
143
+ # не знают о noise injection, и обёртка модели не может обновить
144
+ # x во внешней функции (см. NIModelWrapper — удалена как ошибочная).
145
+ # ═══════════════════════════════════════════════════════════════
146
+
147
+ import tqdm as _tqdm
148
+ from modules import sd_samplers, sd_samplers_common
149
+ import modules.sd_samplers_kdiffusion as K
150
+
151
+
152
+ class TimeMachineEulerNI:
153
+ """
154
+ Euler + Noise Injection. Кастомный цикл — NI модифицирует x
155
+ напрямую, затем вычисляется стандартный Euler-шаг.
156
+ """
157
+ @torch.no_grad()
158
+ def __call__(self, model, x, sigmas, extra_args=None, callback=None, disable=None,
159
+ s_churn=0., s_tmin=0., s_tmax=float('inf'), s_noise=1., **kwargs):
160
+ extra_args = extra_args or {}
161
+ s_in = x.new_ones([x.shape[0]])
162
+ last_sigma: float | None = None
163
+ n_steps = len(sigmas) - 1
164
+
165
+ for i in _tqdm.tqdm(range(n_steps), disable=disable):
166
+ sigma_curr = sigmas[i]
167
+ sigma_next = sigmas[i + 1]
168
+
169
+ # ── Noise Injection (σ выросла → добавить шум) ──────────
170
+ sv = sigma_curr.item()
171
+ if last_sigma is not None and sv > last_sigma:
172
+ delta_var = sv ** 2 - last_sigma ** 2
173
+ if delta_var > 0:
174
+ x = x + torch.randn_like(x) * math.sqrt(delta_var)
175
+ last_sigma = sv
176
+
177
+ # ── s_churn (ε-буст для exploration) ─────────────────────
178
+ gamma = min(s_churn / n_steps, math.sqrt(2) - 1) if s_tmin <= sv <= s_tmax else 0.
179
+ sigma_hat = sigma_curr * (gamma + 1)
180
+ if gamma > 0:
181
+ eps = torch.randn_like(x) * s_noise
182
+ x = x + eps * math.sqrt(sigma_hat ** 2 - sv ** 2)
183
+
184
+ # ── Euler шаг ────────────────────────────────────────────
185
+ denoised = model(x, sigma_hat * s_in, **extra_args)
186
+ if callback is not None:
187
+ callback({'x': x, 'i': i, 'sigma': sigma_hat,
188
+ 'sigma_hat': sigma_hat, 'denoised': denoised})
189
+
190
+ d = (x - denoised) / sigma_hat
191
+ dt = sigma_next - sigma_hat
192
+ x = x + d * dt
193
+
194
+ return x
195
+
196
+
197
+ class TimeMachineDPM2MNI:
198
+ """
199
+ DPM++ 2M + Noise Injection.
200
+
201
+ Кастомный цикл: при NI кеш old_denoised сбрасывается,
202
+ иначе второй порядок использует значение с неверного состояния.
203
+ """
204
+ @torch.no_grad()
205
+ def __call__(self, model, x, sigmas, extra_args=None, callback=None, disable=None, **kwargs):
206
+ extra_args = extra_args or {}
207
+ s_in = x.new_ones([x.shape[0]])
208
+ sigma_fn = lambda t: t.neg().exp()
209
+ t_fn = lambda s: s.log().neg()
210
+ old_denoised = None
211
+ last_sigma: float | None = None
212
+
213
+ for i in _tqdm.tqdm(range(len(sigmas) - 1), disable=disable):
214
+ sigma_curr = sigmas[i]
215
+ sigma_next = sigmas[i + 1]
216
+
217
+ # ── Noise Injection ──────────────────────────────────────
218
+ sv = sigma_curr.item()
219
+ if last_sigma is not None and sv > last_sigma:
220
+ delta_var = sv ** 2 - last_sigma ** 2
221
+ if delta_var > 0:
222
+ x = x + torch.randn_like(x) * math.sqrt(delta_var)
223
+ old_denoised = None # кеш невалиден после re-noise
224
+ last_sigma = sv
225
+
226
+ # ── DPM++ 2M шаг ───────────���─────────────────────────────
227
+ denoised = model(x, sigma_curr * s_in, **extra_args)
228
+ if callback is not None:
229
+ callback({'x': x, 'i': i, 'sigma': sigma_curr,
230
+ 'sigma_hat': sigma_curr, 'denoised': denoised})
231
+
232
+ t, t_next = t_fn(sigma_curr), t_fn(sigma_next)
233
+ h = t_next - t
234
+
235
+ if old_denoised is None or sigma_next == 0 or h == 0:
236
+ # Euler: первый шаг, после NI-сброса, последний шаг,
237
+ # или flat sigma (h=0 — деление на ноль в 2M формулах)
238
+ x = (sigma_fn(t_next) / sigma_fn(t)) * x - (-h).expm1() * denoised
239
+ else:
240
+ # DPM++ 2M второй порядок
241
+ h_last = t - t_fn(sigmas[i - 1])
242
+ r = h_last / h
243
+ denoised_d = (1 + 1 / (2 * r)) * denoised - (1 / (2 * r)) * old_denoised
244
+ x = (sigma_fn(t_next) / sigma_fn(t)) * x - (-h).expm1() * denoised_d
245
+
246
+ old_denoised = denoised
247
+
248
+ return x
249
+
250
+
251
+ # ─────────────────────────────────────────────────────────────────
252
+ # Регистрация NI семплеров в A1111 с поддержкой s_churn
253
+ # ─────────────────────────────────────────────────────────────────
254
+
255
+ class _TMSampler(K.KDiffusionSampler):
256
+ """
257
+ Обёртка над KDiffusionSampler, которая форсирует extra_params
258
+ для s_churn/s_tmin/s_tmax/s_noise (родной A1111 маппинг
259
+ sampler_extra_params не работает для TM-сэмплеров, т.к.
260
+ ключуется по строковому имени функции, а у нас — объект).
261
+ """
262
+ def __init__(self, func, sd_model, options=None):
263
+ super().__init__(func, sd_model, options)
264
+ self.extra_params = ['s_churn', 's_tmin', 's_tmax', 's_noise']
265
+
266
+
267
+ def register_ni_samplers() -> None:
268
+ """
269
+ Добавляет 'TM Euler (NI)' и 'TM DPM++ 2M (NI)' в список семплеров A1111.
270
+ Используются совместно с TimeMachine: sigma override задаёт расписание,
271
+ NI добавляет физически правильный шум при backward-шагах.
272
+ Без TimeMachine ведут себя идентично Euler / DPM++ 2M (NI не срабатывает).
273
+ """
274
+ ni_entries = [
275
+ ('TM Euler (NI)', TimeMachineEulerNI(), ['k_tm_euler_ni'], {}),
276
+ ('TM DPM++ 2M (NI)', TimeMachineDPM2MNI(), ['k_tm_dpmpp2m_ni'], {'scheduler': 'karras'}),
277
+ ]
278
+ added = False
279
+ for label, func, aliases, options in ni_entries:
280
+ if label not in [x.name for x in sd_samplers.all_samplers]:
281
+ # default-аргументы f= и o= фиксируют текущие значения в closure
282
+ data = sd_samplers_common.SamplerData(
283
+ label,
284
+ lambda model, f=func, o=options: _TMSampler(f, model, options=o),
285
+ aliases,
286
+ options,
287
+ )
288
+ sd_samplers.all_samplers.append(data)
289
+ added = True
290
+
291
+ if added:
292
+ sd_samplers.set_samplers()
293
+ sd_samplers.all_samplers_map = {x.name: x for x in sd_samplers.all_samplers}
sd-webui-timemachine-fixed/scripts/timemachinelib/xyz.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # xyz.py — v7: индексы под UI с 9 компонентами
2
+ # [enabled, scheduler, interp_mode, tm, hr_enabled, tm_hr, cutoff_steps, hr_cutoff, one_shot]
3
+ # 0=enabled 1=scheduler 2=interp 4=hr_enabled 6=cutoff_steps 7=hr_cutoff 8=one_shot
4
+
5
+ import os
6
+
7
+ from modules import scripts
8
+ from modules.processing import StableDiffusionProcessingTxt2Img
9
+
10
+
11
+ def __set_value(p, script, index, value):
12
+ args = list(p.script_args)
13
+ all_s = (scripts.scripts_txt2img.scripts if isinstance(p, StableDiffusionProcessingTxt2Img)
14
+ else scripts.scripts_img2img.scripts)
15
+ for idx in [x.args_from for x in all_s if isinstance(x, script)]:
16
+ if idx is not None:
17
+ args[idx + index] = value
18
+ p.script_args = type(p.script_args)(args)
19
+
20
+
21
+ def to_bool(v: str):
22
+ if not v: return False
23
+ v = v.strip().lower()
24
+ if v == 'true': return True
25
+ if v == 'false': return False
26
+ try: return bool(int(v))
27
+ except (ValueError, TypeError):
28
+ raise ValueError('value must be True/False/1/0')
29
+
30
+
31
+ class AxisOptions:
32
+ def __init__(self, AxisOption, axis_options):
33
+ self.AxisOption = AxisOption; self.target = axis_options; self.options = []
34
+ def __enter__(self): self.options.clear(); return self
35
+ def __exit__(self, *_):
36
+ for opt in self.options: self.target.append(opt)
37
+ self.options.clear()
38
+ def create(self, name, type_fn, action, choices=None):
39
+ return (self.AxisOption(name, type_fn, action, choices=lambda: choices)
40
+ if choices else self.AxisOption(name, type_fn, action))
41
+ def add(self, opt): self.options.append(opt)
42
+
43
+
44
+ _xyz_registered = False
45
+
46
+ def init_xyz(script, ext_name):
47
+ global _xyz_registered
48
+ if _xyz_registered: return
49
+ for data in scripts.scripts_data:
50
+ if os.path.basename(data.path) not in ('xy_grid.py','xyz_grid.py'): continue
51
+ if not hasattr(data.module,'AxisOption') or not hasattr(data.module,'axis_options'): continue
52
+ AO = data.module.AxisOption; ao = data.module.axis_options
53
+ if not isinstance(AO, type) or not isinstance(ao, list): continue
54
+ try: _create_options(script, ext_name, AO, ao)
55
+ except Exception as e:
56
+ print(f'[TimeMachine] Не удалось зарегистрировать XYZ-опции: {e}')
57
+ _xyz_registered = True
58
+
59
+
60
+ def _create_options(script, ext_name, AO, ao):
61
+ from scripts.timemachinelib.sampler import get_scheduler_choices
62
+ with AxisOptions(AO, ao) as opts:
63
+ def define(param, index, type_fn, choices=None):
64
+ def fn(p, x, xs): __set_value(p, script, index, x)
65
+ return opts.create(f'{ext_name} {param}', type_fn, fn, choices)
66
+ for opt in [
67
+ define('Enabled', 0, to_bool, ['false','true']),
68
+ define('Scheduler', 1, str, get_scheduler_choices()),
69
+ define('Interpolation', 2, str, ['Linear','Smooth (spline)','Step','Monotone','Ease In','Ease Out','Ease In-Out','Cubic','Exponential']),
70
+ define('HR Enabled', 4, to_bool, ['false','true']),
71
+ define('Cutoff Steps', 6, int),
72
+ define('HR Cutoff', 7, to_bool, ['false','true']),
73
+ define('One-shot', 8, to_bool, ['false','true']),
74
+ ]: opts.add(opt)