dikdimon commited on
Commit
517f0b0
·
verified ·
1 Parent(s): 32dee64

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