dikdimon commited on
Commit
03aa198
·
verified ·
1 Parent(s): 0905af4

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,957 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ function getA1111Scheduler() {
181
+ const el = $$(`#${mode}_scheduler select`);
182
+ return el?.value || '';
183
+ }
184
+
185
+ async function _fetchRealSigmas(scheduler, steps) {
186
+ try {
187
+ let url = `/timemachine/sigmas?scheduler=${encodeURIComponent(scheduler)}&steps=${steps}`;
188
+ const sampler = getSampler();
189
+ if (sampler) url += `&sampler=${encodeURIComponent(sampler)}`;
190
+ const a1111sched = getA1111Scheduler();
191
+ if (a1111sched) url += `&a1111_scheduler=${encodeURIComponent(a1111sched)}`;
192
+ const res = await fetch(url);
193
+ if (!res.ok) return null;
194
+ const data = await res.json();
195
+ return Array.isArray(data.sigmas) ? data.sigmas : null;
196
+ } catch(e) {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ function _karrasApprox(n) {
202
+ const [sMin, sMax, rho] = [0.1, 14.6, 7];
203
+ const sig = Array.from({length: n}, (_, i) => {
204
+ const t = i / Math.max(1, n-1);
205
+ return Math.pow(Math.pow(sMax,1/rho)+t*(Math.pow(sMin,1/rho)-Math.pow(sMax,1/rho)), rho);
206
+ });
207
+ sig.push(0);
208
+ return sig;
209
+ }
210
+
211
+ function updateSigmaApprox(n, scheduler) {
212
+ approxSigmas = _karrasApprox(n);
213
+ _fetchRealSigmas(scheduler, n).then(real => {
214
+ if (real && real.length >= n) {
215
+ // real has n+1 sigmas (including trailing 0)
216
+ approxSigmas = real.slice(0, n + 1);
217
+ if (showSigma) { chart?.update(); if (hr_chart) hr_chart?.update(); }
218
+ }
219
+ });
220
+ }
221
+
222
+ // Динамические callbacks Chart.js
223
+ chart.options.scales.y.ticks.callback = v => {
224
+ if (!showSigma || v < 1 || v > approxSigmas.length-1) return v;
225
+ const s = approxSigmas[Math.round(v)-1];
226
+ return s == null ? v : s < 0.01 ? '0' : s < 1 ? s.toFixed(3) : s.toFixed(1);
227
+ };
228
+ chart.options.plugins.tooltip.callbacks.title = ctxs => ctxs.map(c => {
229
+ const pos = c.parsed.y;
230
+ if (!showSigma) return `${c.parsed.x} → ${pos}`;
231
+ const s = approxSigmas[Math.round(pos)-1];
232
+ return `step ${c.parsed.x} → ${pos}${s != null ? ` (σ≈${s.toFixed(3)})` : ''}`;
233
+ });
234
+
235
+ // Interp tension
236
+ function getInterpMode() {
237
+ return $$(`#${id('interpolation')} select`)?.value || 'Linear';
238
+ }
239
+ function updateChartStyle(c = chart) {
240
+ const mode = getInterpMode();
241
+ const ds = c.data.datasets[0];
242
+ if (mode === 'Step') {
243
+ ds.stepped = 'before'; ds.cubicInterpolationMode = 'default'; ds.tension = 0;
244
+ } else if (mode === 'Monotone') {
245
+ ds.stepped = false; ds.cubicInterpolationMode = 'monotone'; ds.tension = 0;
246
+ } else if (mode === 'Smooth (spline)') {
247
+ ds.stepped = false; ds.cubicInterpolationMode = 'default'; ds.tension = 0.4;
248
+ } else {
249
+ ds.stepped = false; ds.cubicInterpolationMode = 'default'; ds.tension = 0;
250
+ }
251
+ c.update();
252
+ }
253
+ const interpEl = $$(`#${id('interpolation')} select`);
254
+ if (interpEl) interpEl.addEventListener('change', () => updateChartStyle());
255
+ const schedEl = $$(`#${id('scheduler')} select`);
256
+ if (schedEl) schedEl.addEventListener('change', () => {
257
+ updateSigmaApprox(+step_ele.value, getScheduler());
258
+ });
259
+ const samplerEl = $$(`#${mode}_sampler select`);
260
+ if (samplerEl) samplerEl.addEventListener('change', () => {
261
+ updateSigmaApprox(+step_ele.value, getScheduler());
262
+ });
263
+
264
+ // ── История (Undo/Redo) ──────────────────────────────
265
+ const MAX_HIST = 20;
266
+ let hist = [], histIdx = -1;
267
+
268
+ function saveState(c) {
269
+ const snap = JSON.stringify(c.data.datasets[0].data);
270
+ if (hist[histIdx] === snap) return;
271
+ hist = hist.slice(0, histIdx+1);
272
+ hist.push(snap);
273
+ if (hist.length > MAX_HIST) hist.shift(); else histIdx++;
274
+ }
275
+ function undo(c) {
276
+ if (histIdx > 0) {
277
+ histIdx--;
278
+ c.data.datasets[0].data = JSON.parse(hist[histIdx]);
279
+ c.update(); triggerSync(c);
280
+ }
281
+ }
282
+ function redo(c) {
283
+ if (histIdx < hist.length-1) {
284
+ histIdx++;
285
+ c.data.datasets[0].data = JSON.parse(hist[histIdx]);
286
+ c.update(); triggerSync(c);
287
+ }
288
+ }
289
+
290
+ document.addEventListener('keydown', e => {
291
+ const tag = document.activeElement?.tagName;
292
+ if (tag==='INPUT'||tag==='TEXTAREA'||document.activeElement?.contentEditable==='true') return;
293
+ if (!enabled?.checked) return;
294
+ if (e.ctrlKey && e.key==='z' && !e.shiftKey) { e.preventDefault(); undo(chart); }
295
+ if (e.ctrlKey && ((e.key==='z' && e.shiftKey)||e.key==='y')) { e.preventDefault(); redo(chart); }
296
+ });
297
+
298
+ // ── Sync + Persist ──────────────────────────────────
299
+ let syncedData = null, pendingSync = null;
300
+
301
+ function _saveToDisk() {
302
+ try {
303
+ localStorage.setItem(`tm_curve:${mode}`, JSON.stringify(chart.data.datasets[0].data));
304
+ if (hr_chart) {
305
+ localStorage.setItem(`tm_curve:${mode}:hr`, JSON.stringify(hr_chart.data.datasets[0].data));
306
+ }
307
+ } catch(e) { /* quota exceeded, silently ignore */ }
308
+ }
309
+
310
+ function _loadFromDisk() {
311
+ const saved = localStorage.getItem(`tm_curve:${mode}`);
312
+ if (saved) {
313
+ try {
314
+ const pts = JSON.parse(saved);
315
+ if (Array.isArray(pts) && pts.length >= 2) {
316
+ chart.data.datasets[0].data = pts;
317
+ updateSteps(chart, +step_ele.value);
318
+ syncedData = JSON.stringify(chart.data.datasets[0].data);
319
+ return;
320
+ }
321
+ } catch(e) { /* ignore corrupt data */ }
322
+ }
323
+ }
324
+
325
+ function triggerSync(c, key='tm') {
326
+ const data = JSON.stringify(c.data.datasets[0].data);
327
+ if (key==='tm' && data===syncedData) return;
328
+ if (key==='tm_hr' && data===hr_syncedData) return;
329
+ const prom = lib.js2py(mode, key, data).then(() => {
330
+ if (key==='tm') { syncedData = data; pendingSync = null; }
331
+ if (key==='tm_hr') { hr_syncedData = data; hr_pendingSync = null; }
332
+ }).catch(() => {
333
+ if (key==='tm') pendingSync = null;
334
+ if (key==='tm_hr') hr_pendingSync = null;
335
+ });
336
+ if (key==='tm') pendingSync = prom;
337
+ if (key==='tm_hr') hr_pendingSync = prom;
338
+ _saveToDisk();
339
+ }
340
+
341
+ // Hook плагина
342
+ const plugin = plugins[0];
343
+ const _oEnd = plugin.endDrag.bind(plugin);
344
+ plugin.endDrag = (c,a)=>{ _oEnd(c,a); saveState(c); triggerSync(c); };
345
+ const _oAdd = plugin.addPoint.bind(plugin);
346
+ plugin.addPoint = (c,a)=>{ _oAdd(c,a); saveState(c); triggerSync(c); };
347
+ const _oDel = plugin.removePoint.bind(plugin);
348
+ plugin.removePoint = (c,a)=>{ _oDel(c,a); saveState(c); triggerSync(c); };
349
+
350
+ // Инициализация
351
+ updateSteps(chart, +step_ele.value);
352
+ updateSigmaApprox(+step_ele.value, getScheduler());
353
+ _loadFromDisk();
354
+ saveState(chart);
355
+
356
+ let debounceTimer;
357
+ step_ele.addEventListener('input', () => {
358
+ updateSteps(chart, +step_ele.value);
359
+ updateSigmaApprox(+step_ele.value, getScheduler());
360
+ saveState(chart);
361
+ if (cutoffEl) cutoffEl.setAttribute('max', +step_ele.value);
362
+ clearTimeout(debounceTimer);
363
+ debounceTimer = setTimeout(() => triggerSync(chart), 150);
364
+ });
365
+
366
+ // Обновление линии cutoff при изменении значения
367
+ if (cutoffEl) {
368
+ cutoffEl.addEventListener('input', () => {
369
+ chart.update();
370
+ if (hr_chart) hr_chart.update();
371
+ });
372
+ }
373
+
374
+ // ── HR Fix график ────────────────────────────────────
375
+ let hr_chart = null, hr_syncedData = null, hr_pendingSync = null;
376
+ const hr_container = $$('#' + id('hr_container'));
377
+
378
+ if (hr_container) {
379
+ const hr_step_raw = $$(`#${mode}_hires_steps input[type=number]`);
380
+ const hr_step = hr_step_raw || step_ele;
381
+
382
+ const hr_canvas = document.createElement('canvas');
383
+ hr_canvas.width = 512; hr_canvas.height = 512;
384
+ const hr_plugins = createPlugins(hr_canvas, hr_step, cutoffEl);
385
+
386
+ hr_chart = new Chart(hr_canvas.getContext('2d'), {
387
+ type: 'scatter', data: createInitialData(),
388
+ options: createChartOption(), plugins: hr_plugins,
389
+ });
390
+
391
+ hr_chart.options.scales.y.ticks.callback = v => {
392
+ if (!showSigma || v < 1 || v > approxSigmas.length-1) return v;
393
+ const s = approxSigmas[Math.round(v)-1];
394
+ return s == null ? v : s < 1 ? s.toFixed(3) : s.toFixed(1);
395
+ };
396
+ hr_chart.options.plugins.tooltip.callbacks.title = ctxs => ctxs.map(c => {
397
+ const pos = c.parsed.y;
398
+ if (!showSigma) return `HR step ${c.parsed.x} → ${pos}`;
399
+ const s = approxSigmas[Math.round(pos)-1];
400
+ return `HR step ${c.parsed.x} → ${pos}${s != null ? ` (σ≈${s.toFixed(3)})` : ''}`;
401
+ });
402
+
403
+ hr_container.appendChild(hr_canvas);
404
+ updateSteps(hr_chart, +hr_step.value || +step_ele.value);
405
+
406
+ // Load persisted HR curve
407
+ const savedHr = localStorage.getItem(`tm_curve:${mode}:hr`);
408
+ if (savedHr) {
409
+ try {
410
+ const pts = JSON.parse(savedHr);
411
+ if (Array.isArray(pts) && pts.length >= 2) {
412
+ hr_chart.data.datasets[0].data = pts;
413
+ updateSteps(hr_chart, +hr_step.value || +step_ele.value);
414
+ hr_syncedData = JSON.stringify(hr_chart.data.datasets[0].data);
415
+ }
416
+ } catch(e) { /* ignore */ }
417
+ }
418
+
419
+ const hr_plugin = hr_plugins[0];
420
+ const _hoEnd = hr_plugin.endDrag.bind(hr_plugin);
421
+ hr_plugin.endDrag = (c,a)=>{ _hoEnd(c,a); triggerSync(c,'tm_hr'); };
422
+ const _hoAdd = hr_plugin.addPoint.bind(hr_plugin);
423
+ hr_plugin.addPoint = (c,a)=>{ _hoAdd(c,a); triggerSync(c,'tm_hr'); };
424
+ const _hoDel = hr_plugin.removePoint.bind(hr_plugin);
425
+ hr_plugin.removePoint = (c,a)=>{ _hoDel(c,a); triggerSync(c,'tm_hr'); };
426
+
427
+ hr_step.addEventListener('input', () => {
428
+ updateSteps(hr_chart, +hr_step.value || +step_ele.value);
429
+ triggerSync(hr_chart, 'tm_hr');
430
+ });
431
+
432
+ // Синхронизируем tension HR графика с основным
433
+ if (interpEl) interpEl.addEventListener('change', () => updateChartStyle(hr_chart));
434
+ }
435
+
436
+ // ── Generate button ─────────────────────────────────
437
+ let generating = false;
438
+ gradioApp().addEventListener('click', async e => {
439
+ if (e.target !== generate_button) return;
440
+ if (!enabled?.checked) return;
441
+ if (generating) { generating = false; return; }
442
+
443
+ const mainData = JSON.stringify(chart.data.datasets[0].data);
444
+ const hrData = hr_chart ? JSON.stringify(hr_chart.data.datasets[0].data) : null;
445
+ const needMain = mainData !== syncedData;
446
+ const needHR = hrData !== null && hrData !== hr_syncedData;
447
+
448
+ if (needMain || needHR) {
449
+ e.preventDefault(); e.stopPropagation();
450
+ await Promise.all([
451
+ needMain ? (pendingSync || lib.js2py(mode,'tm', mainData).then(()=>syncedData =mainData)) : Promise.resolve(),
452
+ needHR ? (hr_pendingSync || lib.js2py(mode,'tm_hr',hrData ).then(()=>hr_syncedData=hrData )) : Promise.resolve(),
453
+ ]);
454
+ generating = true; generate_button.click();
455
+ }
456
+ }, true);
457
+
458
+ // ── Нижняя панель ───────────────────────────────────
459
+ const PRESETS = {
460
+ 'Linear (Default)': n => [{x:1,y:1},{x:n,y:n}],
461
+ 'Detail Enhancer': n => [{x:1,y:1},{x:Math.max(2,Math.round(n*.25)),y:Math.round(n*.7)},{x:n,y:n}],
462
+ 'Composition Lock': n => [{x:1,y:1},{x:Math.round(n*.75),y:Math.max(2,Math.round(n*.25))},{x:n,y:n}],
463
+ '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}],
464
+ 'Early Burst': n => [{x:1,y:1},{x:Math.round(n*.5),y:Math.round(n*.9)},{x:n,y:n}],
465
+ };
466
+
467
+ function makeBtn(label, title, onClick) {
468
+ const b = document.createElement('button');
469
+ b.textContent = label; b.type = 'button'; b.title = title || label;
470
+ b.style.cssText = 'padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px';
471
+ b.addEventListener('click', onClick); return b;
472
+ }
473
+
474
+ const bar = document.createElement('div');
475
+ bar.style.cssText = 'display:flex;gap:6px;align-items:center;margin:6px 0 0;flex-wrap:wrap;font-size:13px';
476
+
477
+ // ── Пресеты (built-in + custom из localStorage) ─────
478
+ function _storageKeys(prefix) {
479
+ const keys = [];
480
+ for (let i = 0; i < localStorage.length; i++) {
481
+ const k = localStorage.key(i);
482
+ if (k && k.startsWith(prefix)) keys.push(k);
483
+ }
484
+ return keys;
485
+ }
486
+
487
+ function _loadCustomPresets() {
488
+ const out = {};
489
+ for (const k of _storageKeys('tm_presets:')) {
490
+ const name = k.slice('tm_presets:'.length);
491
+ try {
492
+ const raw = JSON.parse(localStorage.getItem(k));
493
+ let pts, savedN;
494
+ if (Array.isArray(raw)) {
495
+ pts = raw;
496
+ savedN = Math.max(...pts.map(p => p.x));
497
+ } else if (raw && Array.isArray(raw.points) && raw.points.length >= 2) {
498
+ pts = raw.points;
499
+ savedN = raw.savedN || Math.max(...pts.map(p => p.x));
500
+ } else {
501
+ continue;
502
+ }
503
+ if (pts.length >= 2) {
504
+ out[name] = (n) => {
505
+ if (n === savedN || savedN <= 1) return pts;
506
+ const scale = (n - 1) / (savedN - 1);
507
+ return pts.map(p => ({
508
+ ...p,
509
+ x: Math.max(1, Math.min(n, Math.round(1 + (p.x - 1) * scale))),
510
+ }));
511
+ };
512
+ }
513
+ } catch(e) { /* ignore */ }
514
+ }
515
+ return out;
516
+ }
517
+
518
+ function _rebuildPresetDropdown() {
519
+ while (presetSel.firstChild) presetSel.removeChild(presetSel.firstChild);
520
+ // Built-in
521
+ Object.keys(PRESETS).forEach(n => {
522
+ const o = document.createElement('option'); o.value = n; o.textContent = n;
523
+ presetSel.appendChild(o);
524
+ });
525
+ // Custom
526
+ const custom = _loadCustomPresets();
527
+ const cKeys = Object.keys(custom);
528
+ if (cKeys.length) {
529
+ const grp = document.createElement('optgroup'); grp.label = '-- Saved --';
530
+ cKeys.forEach(n => {
531
+ const o = document.createElement('option'); o.value = n; o.textContent = n;
532
+ grp.appendChild(o);
533
+ });
534
+ presetSel.appendChild(grp);
535
+ }
536
+ // Store custom map on the select for the apply handler
537
+ presetSel._custom = custom;
538
+ }
539
+
540
+ const presetSel = document.createElement('select');
541
+ presetSel.style.cssText = 'padding:3px 6px;border-radius:4px;cursor:pointer;flex:1;min-width:120px';
542
+ _rebuildPresetDropdown();
543
+
544
+ const applyBtn = makeBtn('Apply', 'Применить пресет', () => {
545
+ const name = presetSel.value;
546
+ const fn = PRESETS[name] || presetSel._custom[name];
547
+ if (!fn) return;
548
+ const n = Math.max(2, +step_ele.value);
549
+ chart.data.datasets[0].data = fn(n);
550
+ updateSteps(chart, n);
551
+ saveState(chart); updateChartStyle(); triggerSync(chart);
552
+ });
553
+
554
+ const savePresetBtn = makeBtn('Save', 'Сохранить как пресет', () => {
555
+ const name = prompt('Название пресета:', '');
556
+ if (!name) return;
557
+ try {
558
+ const pts = chart.data.datasets[0].data.map(p => ({...p}));
559
+ localStorage.setItem(`tm_presets:${name}`, JSON.stringify({
560
+ savedN: Math.max(2, +step_ele.value),
561
+ points: pts,
562
+ }));
563
+ } catch(e) { alert('Не удалось сохранить пресет (превышен лимит localStorage).'); return; }
564
+ _rebuildPresetDropdown();
565
+ presetSel.value = name;
566
+ });
567
+
568
+ const delPresetBtn = makeBtn('Del', 'Удалить выбранный пресет', () => {
569
+ const name = presetSel.value;
570
+ if (!presetSel._custom[name]) return;
571
+ if (!confirm(`Удалить пресет "${name}"?`)) return;
572
+ localStorage.removeItem(`tm_presets:${name}`);
573
+ _rebuildPresetDropdown();
574
+ });
575
+
576
+ const resetBtn = makeBtn('Reset', 'Сбросить кривую к прямой', () => {
577
+ const n = Math.max(2, +step_ele.value);
578
+ chart.data.datasets[0].data = [{x:1,y:1},{x:n,y:n}];
579
+ saveState(chart); updateChartStyle(); triggerSync(chart);
580
+ });
581
+
582
+ // Undo / Redo
583
+ const undoBtn = makeBtn('Undo', 'Отменить (Ctrl+Z)', () => undo(chart));
584
+ const redoBtn = makeBtn('Redo', 'Повторить (Ctrl+Shift+Z)', () => redo(chart));
585
+
586
+ // Separator
587
+ const sep = () => { const s = document.createElement('span'); s.textContent='|'; s.style.opacity='.3'; return s; };
588
+
589
+ // Export
590
+ const exportBtn = makeBtn('Export', 'Сохранить кривую в JSON', () => {
591
+ const n = prompt('Название пресета:', 'my_curve') || 'my_curve';
592
+ const data = {
593
+ name: n, version: 1,
594
+ points: chart.data.datasets[0].data,
595
+ interpolation: getInterpMode(),
596
+ };
597
+ const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});
598
+ const url = URL.createObjectURL(blob);
599
+ const a = document.createElement('a');
600
+ a.href = url; a.download = `${n.replace(/\s+/g,'_')}.json`; a.click();
601
+ URL.revokeObjectURL(url);
602
+ });
603
+
604
+ // Import
605
+ const importBtn = makeBtn('Import', 'Загрузить кривую из JSON', () => {
606
+ const inp = document.createElement('input');
607
+ inp.type = 'file'; inp.accept = '.json';
608
+ inp.addEventListener('change', () => {
609
+ const file = inp.files[0]; if (!file) return;
610
+ const reader = new FileReader();
611
+ reader.onload = ev => {
612
+ try {
613
+ const d = JSON.parse(ev.target.result);
614
+ if (!Array.isArray(d.points)) return;
615
+ const n = Math.max(2, +step_ele.value);
616
+ const pts = d.points.filter(p => p.x>=1&&p.x<=n&&p.y>=1&&p.y<=n);
617
+ chart.data.datasets[0].data = pts;
618
+ updateSteps(chart, n);
619
+ saveState(chart); triggerSync(chart);
620
+ } catch(err) { console.error('[TimeMachine] Import error:', err); }
621
+ };
622
+ reader.readAsText(file);
623
+ });
624
+ inp.click();
625
+ });
626
+
627
+ // Show sigma
628
+ const sigmaBtn = makeBtn('Sigma', 'Показать σ-значения на оси Y (реальные через HTTP, fallback Karras)', () => {
629
+ showSigma = !showSigma;
630
+ sigmaBtn.textContent = showSigma ? 'Step' : 'Sigma';
631
+ chart.update(); if (hr_chart) hr_chart.update();
632
+ });
633
+
634
+ // ── Panel system ───────────────────────────────────────���────────
635
+ let _panelDiv = null;
636
+ function _closePanel() { if (_panelDiv) { _panelDiv.remove(); _panelDiv = null; } }
637
+
638
+ function _openPanel(title, buildFn) {
639
+ _closePanel();
640
+ const d = document.createElement('div');
641
+ d.id = id('panel');
642
+ 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';
643
+ const h = document.createElement('div');
644
+ h.style.cssText = 'font-weight:bold;margin-bottom:6px';
645
+ h.textContent = title;
646
+ d.appendChild(h);
647
+ buildFn(d);
648
+ $('#' + id('container')).appendChild(d);
649
+ _panelDiv = d;
650
+ }
651
+
652
+ // ── Gen… (procedural curves) ────────────────────────────────────
653
+ const genBtn = makeBtn('Gen…', 'Сгенерировать кривую (синус / случайная)', () => {
654
+ let prevData = chart.data.datasets[0].data.slice();
655
+ function getN() { return Math.max(2, +step_ele.value); }
656
+
657
+ function _sine(freq, amp) {
658
+ const n = getN();
659
+ const pts = [{x:1,y:1}];
660
+ for (let x = 2; x < n; x++) {
661
+ const t = (x-1)/(n-1);
662
+ const mid = 1 + (n-1)*t;
663
+ const y = Math.round(mid + amp*(n-1)*Math.sin(2*Math.PI*freq*t));
664
+ pts.push({x, y: Math.max(1, Math.min(n, y))});
665
+ }
666
+ pts.push({x:n, y:n});
667
+ return pts;
668
+ }
669
+
670
+ function _walk(stepSz, seed) {
671
+ const n = getN();
672
+ let rng = seed || Date.now();
673
+ function rand() { rng ^= rng<<13; rng ^= rng>>17; rng ^= rng<<5; return (rng>>>0)/4294967296; }
674
+ const pts = [{x:1,y:1}];
675
+ let y = 1;
676
+ for (let x = 2; x < n; x++) {
677
+ y += (rand()-0.5)*2*stepSz;
678
+ y = Math.max(1, Math.min(n, Math.round(y)));
679
+ pts.push({x, y});
680
+ }
681
+ pts.push({x:n, y:n});
682
+ return pts;
683
+ }
684
+
685
+ _openPanel('Generate Curve', panel => {
686
+ const row1 = document.createElement('div');
687
+ row1.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:4px';
688
+ const sel = document.createElement('select');
689
+ sel.innerHTML = '<option>Sine</option><option>Random walk</option>';
690
+
691
+ const p1Label = document.createElement('span');
692
+ p1Label.style.cssText = 'width:55px;text-align:right';
693
+ const p1 = document.createElement('input');
694
+ p1.type = 'range'; p1.min = 0.5; p1.max = 10; p1.step = 0.5; p1.value = 2;
695
+
696
+ const p2Label = document.createElement('span');
697
+ p2Label.style.cssText = 'width:55px;text-align:right';
698
+ const p2 = document.createElement('input');
699
+ p2.type = 'range'; p2.min = 0; p2.max = 1; p2.step = 0.05; p2.value = 0.3;
700
+
701
+ function updateLabels() {
702
+ if (sel.value === 'Sine') {
703
+ p1Label.textContent = `Freq: ${p1.value}`;
704
+ p1.min = 0.5; p1.max = 10; p1.step = 0.5;
705
+ p2Label.textContent = `Amp: ${p2.value}`;
706
+ p2.min = 0; p2.max = 1; p2.step = 0.05;
707
+ } else {
708
+ p1Label.textContent = `Step: ${p1.value}`;
709
+ p1.min = 0.1; p1.max = 5; p1.step = 0.1;
710
+ p2Label.textContent = `Seed: ${p2.value}`;
711
+ p2.min = 0; p2.max = 9999; p2.step = 1;
712
+ }
713
+ }
714
+
715
+ function preview() {
716
+ const n = getN();
717
+ const pts = sel.value === 'Sine' ? _sine(+p1.value, +p2.value) : _walk(+p1.value, +p2.value);
718
+ chart.data.datasets[0].data = pts;
719
+ updateSteps(chart, n);
720
+ chart.update();
721
+ }
722
+
723
+ sel.addEventListener('change', () => { updateLabels(); preview(); });
724
+ p1.addEventListener('input', () => { updateLabels(); preview(); });
725
+ p2.addEventListener('input', () => { updateLabels(); preview(); });
726
+ updateLabels(); preview();
727
+
728
+ const apply = makeBtn('Apply', '', () => {
729
+ saveState(chart); updateChartStyle(); triggerSync(chart);
730
+ _closePanel();
731
+ });
732
+ const cancel = makeBtn('Cancel', '', () => {
733
+ const n = getN();
734
+ chart.data.datasets[0].data = prevData;
735
+ updateSteps(chart, n); chart.update();
736
+ _closePanel();
737
+ });
738
+ row1.append(sel, p1Label, p1, p2Label, p2, apply, cancel);
739
+ panel.appendChild(row1);
740
+ });
741
+ });
742
+
743
+ // ── Rasterize control points → dense array of length n ───────
744
+ const _WEIGHT = {
745
+ 'Ease In': t => t * t,
746
+ 'Ease Out': t => 1 - (1-t)*(1-t),
747
+ 'Ease In-Out': t => t*t*(3-2*t),
748
+ 'Cubic': t => t*t*t,
749
+ 'Exponential': t => Math.pow(2,t)-1,
750
+ 'Step': t => 0,
751
+ };
752
+
753
+ function _rasterize(pts, n) {
754
+ const sorted = [...pts].sort((a,b)=>a.x-b.x);
755
+ const out = [];
756
+ let si = 0;
757
+ for (let x = 1; x <= n; x++) {
758
+ while (si < sorted.length-2 && sorted[si+1].x <= x) si++;
759
+ const p0 = sorted[si], p1 = sorted[Math.min(si+1, sorted.length-1)];
760
+ let y;
761
+ if (p0.x === p1.x) { y = p0.y; }
762
+ else {
763
+ const mode = p0.mode || '';
764
+ const t = (x-p0.x)/(p1.x-p0.x);
765
+ if (mode === 'Step') { y = p0.y; }
766
+ else {
767
+ const w = _WEIGHT[mode] || (t => t);
768
+ y = p0.y + w(t) * (p1.y - p0.y);
769
+ }
770
+ }
771
+ out.push({x, y: Math.max(1, Math.min(n, Math.round(y)))});
772
+ }
773
+ return out;
774
+ }
775
+
776
+ // ── Blend… ──────────────────────────────────────────────────────
777
+ const blendBtn = makeBtn('Blend…', 'Смешать два пресета', () => {
778
+ const n = Math.max(2, +step_ele.value);
779
+ let prevData = chart.data.datasets[0].data.slice();
780
+ const allPresets = {...PRESETS, ...presetSel._custom};
781
+ const names = Object.keys(allPresets);
782
+
783
+ _openPanel('Blend Presets', panel => {
784
+ const row = document.createElement('div');
785
+ row.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap';
786
+
787
+ const sA = document.createElement('select');
788
+ const sB = document.createElement('select');
789
+ names.forEach(name => { sA.innerHTML += `<option>${name}</option>`; sB.innerHTML += `<option>${name}</option>`; });
790
+ if (names.length > 1) sB.selectedIndex = 1;
791
+
792
+ const sl = document.createElement('input');
793
+ sl.type = 'range'; sl.min = 0; sl.max = 100; sl.value = 50;
794
+ const slLabel = document.createElement('span');
795
+ slLabel.style.cssText = 'width:40px';
796
+
797
+ function _getPresetFn(name) { return PRESETS[name] || presetSel._custom[name]; }
798
+
799
+ function _blend() {
800
+ const fnA = _getPresetFn(sA.value);
801
+ const fnB = _getPresetFn(sB.value);
802
+ if (!fnA || !fnB) return null;
803
+ const cA = fnA(n), cB = fnB(n);
804
+ const denseA = _rasterize(cA, n);
805
+ const denseB = _rasterize(cB, n);
806
+ const t = +sl.value / 100;
807
+ slLabel.textContent = `${sl.value}%`;
808
+ return denseA.map((pt, i) => ({
809
+ x: pt.x,
810
+ y: Math.round(pt.y*(1-t) + denseB[i].y*t),
811
+ }));
812
+ }
813
+
814
+ function preview() { const pts = _blend(); if (pts) { chart.data.datasets[0].data = pts; updateSteps(chart, n); chart.update(); } }
815
+
816
+ sA.addEventListener('change', preview);
817
+ sB.addEventListener('change', preview);
818
+ sl.addEventListener('input', preview);
819
+ preview();
820
+
821
+ const apply = makeBtn('Apply', '', () => { saveState(chart); updateChartStyle(); triggerSync(chart); _closePanel(); });
822
+ const cancel = makeBtn('Cancel', '', () => { chart.data.datasets[0].data = prevData; updateSteps(chart, n); chart.update(); _closePanel(); });
823
+
824
+ row.append(sA, sB, sl, slLabel, apply, cancel);
825
+ panel.appendChild(row);
826
+ });
827
+ });
828
+
829
+ bar.append(presetSel, applyBtn, savePresetBtn, delPresetBtn, resetBtn, sep(), undoBtn, redoBtn, sep(), exportBtn, importBtn, sep(), sigmaBtn, genBtn, blendBtn);
830
+ $('#' + id('container')).appendChild(bar);
831
+
832
+ // ── One-shot: автосброс Enabled после генерации ────────────────
833
+ // Паттерн из script.js A1111: следим за interruptBtn.style.display
834
+ if (oneShotEl) {
835
+ const interruptBtn = gradioApp().querySelector(`#${mode}_interrupt`);
836
+ if (interruptBtn) {
837
+ let wasGen = false;
838
+ new MutationObserver(() => {
839
+ const isGen = interruptBtn.style.display === 'block';
840
+ if (isGen && !wasGen) {
841
+ wasGen = true;
842
+ } else if (!isGen && wasGen) {
843
+ wasGen = false;
844
+ if (oneShotEl.checked && enabled?.checked) {
845
+ enabled.checked = false;
846
+ enabled.dispatchEvent(new Event('change', { bubbles: true }));
847
+ }
848
+ }
849
+ }).observe(interruptBtn, { attributes: true, attributeFilter: ['style'] });
850
+ }
851
+ }
852
+ }
853
+
854
+ // ── Общие функции ────────────────────────────────────────────────
855
+
856
+ function createInitialData() {
857
+ return { datasets: [{
858
+ type:'line', showLine:true, tension:0,
859
+ backgroundColor:'rgba(255,140,0,.6)', borderColor:'rgba(255,140,0,.6)',
860
+ borderWidth:2, borderCapStyle:'round', borderJoinStyle:'round',
861
+ borderDash:[], borderDashOffset:0,
862
+ pointBorderColor:'rgba(255,140,0,.6)', pointBackgroundColor:'rgba(255,140,0,.6)',
863
+ pointBorderWidth:1, pointHoverBorderWidth:10, pointRadius:5, pointHitRadius:10,
864
+ fill:false, data:[],
865
+ }]};
866
+ }
867
+
868
+ function createChartOption() {
869
+ return {
870
+ responsive:false,
871
+ events:['mouseup','mousedown','mousemove','mouseout','click','touchstart','touchmove'],
872
+ scales:{
873
+ x:{type:'linear',display:true,title:{display:true,text:'timesteps'},ticks:{major:{enabled:true}},min:0,max:100,stepSize:10},
874
+ y:{type:'linear',display:true,title:{display:true,text:'actual timesteps'},ticks:{major:{enabled:true}},min:0,max:100,stepSize:10},
875
+ },
876
+ chartArea:{backgroundColor:'rgba(255,255,255,1)'},
877
+ animation:{duration:100},
878
+ plugins:{legend:{display:false},tooltip:{callbacks:{
879
+ title:ctxs=>ctxs.map(c=>`${c.parsed.x} → ${c.parsed.y}`), label:()=>'',
880
+ }}},
881
+ };
882
+ }
883
+
884
+ function createPlugins(canvas, step_ele, cutoffEl) {
885
+ const plugins = [{
886
+ id:'dragpoint',
887
+ beforeEvent(chart, args) {
888
+ const t = args.event.type;
889
+ if (t === 'mousedown') {
890
+ const btn = args.event.native.button;
891
+ if (btn===0) this.startDrag(chart, args);
892
+ else if (btn===2) this.removePoint(chart, args);
893
+ } else if (t==='mouseup'||t==='mouseout') {
894
+ if (this.dragctx.item) this.endDrag(chart, args);
895
+ } else if (t==='mousemove') {
896
+ if (this.dragctx.item) this.onDrag(chart, args);
897
+ }
898
+ },
899
+ afterDraw(chart) {
900
+ const ctx = chart.ctx; ctx.save();
901
+ // Cutoff line
902
+ const cs = cutoffEl ? +cutoffEl.value : 0;
903
+ if (cs > 0) {
904
+ const xa = chart.scales.x, ya = chart.scales.y, x = xa.getPixelForValue(cs);
905
+ ctx.beginPath(); ctx.setLineDash([6, 4]); ctx.strokeStyle = 'rgba(220,50,50,0.6)';
906
+ ctx.lineWidth = 2; ctx.moveTo(x, ya.top); ctx.lineTo(x, ya.bottom); ctx.stroke();
907
+ }
908
+ // Per-segment mode indicators
909
+ const data = chart.data.datasets[0].data;
910
+ for (let i = 0; i < data.length - 1; i++) {
911
+ const mode = data[i].mode;
912
+ if (!mode) continue;
913
+ const x0 = chart.scales.x.getPixelForValue(data[i].x);
914
+ const y0 = chart.scales.y.getPixelForValue(data[i].y);
915
+ const x1 = chart.scales.x.getPixelForValue(data[i+1].x);
916
+ const y1 = chart.scales.y.getPixelForValue(data[i+1].y);
917
+ const mx = (x0+x1)/2, my = (y0+y1)/2;
918
+ ctx.beginPath(); ctx.arc(mx, my, 5, 0, 2*Math.PI);
919
+ ctx.fillStyle = '#ff8c00'; ctx.fill();
920
+ ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
921
+ ctx.fillStyle = '#fff';
922
+ ctx.font = 'bold 8px sans-serif';
923
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
924
+ ctx.fillText(mode[0], mx, my);
925
+ }
926
+ ctx.restore();
927
+ },
928
+ dragctx:{item:null, max_steps:()=>Math.max(1,+step_ele.value)},
929
+ lastRemoved:false,
930
+ startDrag(chart,args){const item=this.getItem(chart,args);if(item)this.dragctx.item=item;else this.addPoint(chart,args);},
931
+ 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();}},
932
+ endDrag(chart){this.dragctx.item=null;},
933
+ 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});},
934
+ 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();}}},
935
+ 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()));},
936
+ 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()));},
937
+ getItem(chart,args){const items=chart.getElementsAtEventForMode(args.event,'nearest',{intersect:true},false);return items.length===0?null:items[0];},
938
+ 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();},
939
+ }];
940
+
941
+ return plugins;
942
+ }
943
+
944
+ function updateSteps(chart, max_steps) {
945
+ max_steps = Math.max(2, max_steps);
946
+ chart.options.scales.x.max = max_steps+1;
947
+ chart.options.scales.y.max = max_steps+1;
948
+ const data = chart.data.datasets[0].data;
949
+ const new_data = [];
950
+ for (const pt of data) if(1<=pt.x&&pt.x<=max_steps) new_data.push({...pt, y:Math.min(pt.y,max_steps)});
951
+ if (!new_data.find(c=>c.x===1)) new_data.unshift({x:1, y:1});
952
+ if (!new_data.find(c=>c.x===max_steps)) new_data.push ({x:max_steps,y:max_steps});
953
+ chart.data.datasets[0].data = new_data;
954
+ chart.update();
955
+ }
956
+
957
+ })('TimeMachine');
sd-webui-timemachine-fixed/scripts/timemachine.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
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 _resolve_scheduler_label(label: str) -> str:
361
+ """Конвертирует label из UI во внутреннее имя."""
362
+ if label in ('Use sampler default', 'Automatic', 'automatic'):
363
+ return label
364
+ from modules import sd_schedulers
365
+ return next((s.name for s in sd_schedulers.schedulers if s.label == label), 'karras')
366
+
367
+
368
+ def _on_app_started(demo, app):
369
+ @app.get("/timemachine/sigmas")
370
+ def get_sigmas(scheduler: str = 'karras', steps: int = 20,
371
+ sampler: str | None = None, a1111_scheduler: str | None = None):
372
+ try:
373
+ if scheduler == 'Use sampler default':
374
+ if a1111_scheduler and a1111_scheduler != 'Automatic':
375
+ scheduler = _resolve_scheduler_label(a1111_scheduler)
376
+ else:
377
+ scheduler = sampler_utils.get_default_scheduler_for(
378
+ sampler_name=sampler, scheduler_name='Automatic')
379
+ sigmas = sampler_utils.compute_sigmas(steps, scheduler)
380
+ return {"sigmas": sigmas.tolist()}
381
+ except Exception as e:
382
+ print(f'[TimeMachine] sigmas API error: {e}')
383
+ return JSONResponse(content={"sigmas": []}, status_code=500)
384
+
385
+ 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,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(p=None, *, sampler_name=None, scheduler_name=None, is_hr_pass=False) -> str:
114
+ """Резолвит scheduler: сначала p.scheduler (реальный выбор Schedule type),
115
+ только если 'Automatic' — дефолт, зашитый в конфиге сэмплера.
116
+ Реплицирует modules/sd_samplers_kdiffusion.py:86-88."""
117
+ if p is not None:
118
+ scheduler_name = (p.hr_scheduler if is_hr_pass else p.scheduler) or 'Automatic'
119
+ sampler_name = p.sampler_name
120
+ sched = scheduler_name or 'Automatic'
121
+ if sched == 'Automatic' and sampler_name:
122
+ from modules import sd_samplers
123
+ config = sd_samplers.find_sampler_config(sampler_name)
124
+ sched = (config.options.get('scheduler') if config else None) or 'karras'
125
+ return sched
126
+
127
+
128
+ def get_scheduler_choices() -> list[str]:
129
+ """Список планировщиков для UI-дропдауна."""
130
+ skip = {'automatic', 'Automatic'}
131
+ return ['Use sampler default'] + [
132
+ s.label
133
+ for s in sd_schedulers.schedulers
134
+ if s.name not in skip and s.label not in skip and s.function is not None
135
+ ]
136
+
137
+
138
+ UNSUPPORTED_SAMPLERS = {'DPM fast', 'DPM adaptive'}
139
+
140
+
141
+ # ═══════════════════════════════════════════════════════════════
142
+ # Noise Injection (NI) семплеры
143
+ #
144
+ # Физическая идея: когда σ растёт между шагами (Time Travel),
145
+ # латент находится «слишком чистым» для текущего уровня шума.
146
+ # Правильное решение — добавить шум: x += √(σ²_{k+1} − σ²_k) · ε
147
+ #
148
+ # Кастомные циклы обязательны: стандартные сэмплеры k-diffusion
149
+ # не знают о noise injection, и обёртка модели не может обновить
150
+ # x во внешней функции (см. NIModelWrapper — удалена как ошибочная).
151
+ # ═══════════════════════════════════════════════════════════════
152
+
153
+ import tqdm as _tqdm
154
+ from modules import sd_samplers, sd_samplers_common
155
+ import modules.sd_samplers_kdiffusion as K
156
+
157
+
158
+ class TimeMachineEulerNI:
159
+ """
160
+ Euler + Noise Injection. Кастомный цикл — NI модифицирует x
161
+ напрямую, затем вычисляется стандартный Euler-шаг.
162
+ """
163
+ @torch.no_grad()
164
+ def __call__(self, model, x, sigmas, extra_args=None, callback=None, disable=None,
165
+ s_churn=0., s_tmin=0., s_tmax=float('inf'), s_noise=1., **kwargs):
166
+ extra_args = extra_args or {}
167
+ s_in = x.new_ones([x.shape[0]])
168
+ last_sigma: float | None = None
169
+ n_steps = len(sigmas) - 1
170
+
171
+ for i in _tqdm.tqdm(range(n_steps), disable=disable):
172
+ sigma_curr = sigmas[i]
173
+ sigma_next = sigmas[i + 1]
174
+
175
+ # ── Noise Injection (σ выросла → добавить шум) ──────────
176
+ sv = sigma_curr.item()
177
+ if last_sigma is not None and sv > last_sigma:
178
+ delta_var = sv ** 2 - last_sigma ** 2
179
+ if delta_var > 0:
180
+ x = x + torch.randn_like(x) * math.sqrt(delta_var)
181
+ last_sigma = sv
182
+
183
+ # ── s_churn (ε-буст для exploration) ─────────────────────
184
+ gamma = min(s_churn / n_steps, math.sqrt(2) - 1) if s_tmin <= sv <= s_tmax else 0.
185
+ sigma_hat = sigma_curr * (gamma + 1)
186
+ if gamma > 0:
187
+ eps = torch.randn_like(x) * s_noise
188
+ x = x + eps * math.sqrt(sigma_hat ** 2 - sv ** 2)
189
+
190
+ # ── Euler шаг ────────────────────────────────────────────
191
+ denoised = model(x, sigma_hat * s_in, **extra_args)
192
+ if callback is not None:
193
+ callback({'x': x, 'i': i, 'sigma': sigma_hat,
194
+ 'sigma_hat': sigma_hat, 'denoised': denoised})
195
+
196
+ d = (x - denoised) / sigma_hat
197
+ dt = sigma_next - sigma_hat
198
+ x = x + d * dt
199
+
200
+ return x
201
+
202
+
203
+ class TimeMachineDPM2MNI:
204
+ """
205
+ DPM++ 2M + Noise Injection.
206
+
207
+ Кастомный цикл: при NI кеш old_denoised сбрасывается,
208
+ иначе второй порядок использует значение с неверного состояния.
209
+ """
210
+ @torch.no_grad()
211
+ def __call__(self, model, x, sigmas, extra_args=None, callback=None, disable=None, **kwargs):
212
+ extra_args = extra_args or {}
213
+ s_in = x.new_ones([x.shape[0]])
214
+ sigma_fn = lambda t: t.neg().exp()
215
+ t_fn = lambda s: s.log().neg()
216
+ old_denoised = None
217
+ last_sigma: float | None = None
218
+
219
+ for i in _tqdm.tqdm(range(len(sigmas) - 1), disable=disable):
220
+ sigma_curr = sigmas[i]
221
+ sigma_next = sigmas[i + 1]
222
+
223
+ # ── Noise Injection ───────────────────────────��──────────
224
+ sv = sigma_curr.item()
225
+ if last_sigma is not None and sv > last_sigma:
226
+ delta_var = sv ** 2 - last_sigma ** 2
227
+ if delta_var > 0:
228
+ x = x + torch.randn_like(x) * math.sqrt(delta_var)
229
+ old_denoised = None # кеш невалиден после re-noise
230
+ last_sigma = sv
231
+
232
+ # ── DPM++ 2M шаг ─────────────────────────────────────────
233
+ denoised = model(x, sigma_curr * s_in, **extra_args)
234
+ if callback is not None:
235
+ callback({'x': x, 'i': i, 'sigma': sigma_curr,
236
+ 'sigma_hat': sigma_curr, 'denoised': denoised})
237
+
238
+ t, t_next = t_fn(sigma_curr), t_fn(sigma_next)
239
+ h = t_next - t
240
+
241
+ if old_denoised is None or sigma_next == 0 or h == 0:
242
+ # Euler: первый шаг, после NI-сброса, последний шаг,
243
+ # или flat sigma (h=0 — деление на ноль в 2M формулах)
244
+ x = (sigma_fn(t_next) / sigma_fn(t)) * x - (-h).expm1() * denoised
245
+ else:
246
+ # DPM++ 2M второй порядок
247
+ h_last = t - t_fn(sigmas[i - 1])
248
+ r = h_last / h
249
+ denoised_d = (1 + 1 / (2 * r)) * denoised - (1 / (2 * r)) * old_denoised
250
+ x = (sigma_fn(t_next) / sigma_fn(t)) * x - (-h).expm1() * denoised_d
251
+
252
+ old_denoised = denoised
253
+
254
+ return x
255
+
256
+
257
+ # ─────────────────────────────────────────────────────────────────
258
+ # Регистрация NI семплеров в A1111 с поддержкой s_churn
259
+ # ─────────────────────────────────────────────────────────────────
260
+
261
+ class _TMSampler(K.KDiffusionSampler):
262
+ """
263
+ Обёртка над KDiffusionSampler, которая форсирует extra_params
264
+ для s_churn/s_tmin/s_tmax/s_noise (родной A1111 маппинг
265
+ sampler_extra_params не работает для TM-сэмплеров, т.к.
266
+ ключуется по строковому имени функции, а у нас — объект).
267
+ """
268
+ def __init__(self, func, sd_model, options=None):
269
+ super().__init__(func, sd_model, options)
270
+ self.extra_params = ['s_churn', 's_tmin', 's_tmax', 's_noise']
271
+
272
+
273
+ def register_ni_samplers() -> None:
274
+ """
275
+ Добавляет 'TM Euler (NI)' и 'TM DPM++ 2M (NI)' в список семплеров A1111.
276
+ Используются совместно с TimeMachine: sigma override задаёт расписание,
277
+ NI добавляет физически правильный шум при backward-шагах.
278
+ Без TimeMachine ведут себя идентично Euler / DPM++ 2M (NI не срабатывает).
279
+ """
280
+ ni_entries = [
281
+ ('TM Euler (NI)', TimeMachineEulerNI(), ['k_tm_euler_ni'], {}),
282
+ ('TM DPM++ 2M (NI)', TimeMachineDPM2MNI(), ['k_tm_dpmpp2m_ni'], {'scheduler': 'karras'}),
283
+ ]
284
+ added = False
285
+ for label, func, aliases, options in ni_entries:
286
+ if label not in [x.name for x in sd_samplers.all_samplers]:
287
+ # default-аргументы f= и o= фиксируют текущие значения в closure
288
+ data = sd_samplers_common.SamplerData(
289
+ label,
290
+ lambda model, f=func, o=options: _TMSampler(f, model, options=o),
291
+ aliases,
292
+ options,
293
+ )
294
+ sd_samplers.all_samplers.append(data)
295
+ added = True
296
+
297
+ if added:
298
+ sd_samplers.set_samplers()
299
+ 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)