amonshano commited on
Commit
eafbe80
·
verified ·
1 Parent(s): 00c7b31

Add Echo-Memory codebase used for this run (CC BY 4.0, JD Echo Team) (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +13 -0
  2. code/diffsynth/tokenizer_configs/stable_diffusion_3/tokenizer_3/tokenizer.json +0 -0
  3. code/diffsynth/tokenizer_configs/stable_diffusion_3/tokenizer_3/tokenizer_config.json +940 -0
  4. code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/merges.txt +0 -0
  5. code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/special_tokens_map.json +24 -0
  6. code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/tokenizer_config.json +38 -0
  7. code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/vocab.json +0 -0
  8. code/diffsynth/trainers/__init__.py +0 -0
  9. code/diffsynth/trainers/latent_dataset.py +130 -0
  10. code/diffsynth/trainers/text_to_image.py +318 -0
  11. code/diffsynth/trainers/utils.py +1368 -0
  12. code/diffsynth/trainers/utils.py.p0bak-getitem +1367 -0
  13. code/diffsynth/vram_management/__init__.py +2 -0
  14. code/diffsynth/vram_management/gradient_checkpointing.py +34 -0
  15. code/diffsynth/vram_management/layers.py +170 -0
  16. code/doc/DEVELOPER.md +116 -0
  17. code/doc/README.md +13 -0
  18. code/doc/checkpoints.md +72 -0
  19. code/doc/dataset_preprocessing.md +181 -0
  20. code/doc/dynamic_dataset_preprocessing.md +159 -0
  21. code/doc/memory_mechanisms.md +41 -0
  22. code/docs/.nojekyll +0 -0
  23. code/docs/README.md +16 -0
  24. code/docs/assets/echo-memory-paper.pdf +3 -0
  25. code/docs/assets/opendomain_revisit/README.md +19 -0
  26. code/docs/assets/paper_cases/README.md +13 -0
  27. code/docs/assets/readme_previews/context_k1_replay_gt.gif +3 -0
  28. code/docs/assets/readme_previews/context_k5_replay_gt.gif +3 -0
  29. code/docs/assets/readme_previews/dyn_context_k1_replay.gif +3 -0
  30. code/docs/assets/readme_previews/dyn_context_k20_replay.gif +3 -0
  31. code/docs/assets/readme_previews/dyn_context_k5_replay.gif +3 -0
  32. code/docs/assets/readme_previews/dyn_spatial_memory_replay.gif +3 -0
  33. code/docs/assets/readme_previews/dyn_ssm_blockwise_replay.gif +3 -0
  34. code/docs/assets/readme_previews/dyn_ssm_legacy_replay.gif +3 -0
  35. code/docs/assets/readme_previews/framepack_len_r4_replay_gt.gif +3 -0
  36. code/docs/assets/readme_previews/spatial_memory_replay_gt.gif +3 -0
  37. code/docs/assets/readme_previews/ssm_blockwise_replay_gt.gif +3 -0
  38. code/docs/assets/readme_previews/ssm_legacy_replay_gt.gif +3 -0
  39. code/docs/developer.html +109 -0
  40. code/docs/i18n-runtime.js +141 -0
  41. code/docs/i18n.js +329 -0
  42. code/docs/index.html +644 -0
  43. code/docs/site.js +349 -0
  44. code/docs/style.css +1848 -0
  45. code/environment.yml +13 -0
  46. code/eval/metrics/README.md +120 -0
  47. code/eval/metrics/VISUAL_EVAL_DESIGN.md +60 -0
  48. code/eval/metrics/__init__.py +2 -0
  49. code/eval/metrics/common.py +79 -0
  50. code/eval/metrics/identity_preservation.py +183 -0
.gitattributes CHANGED
@@ -47,3 +47,16 @@ code/assets/readme_previews/ssm_blockwise_replay_gt.gif filter=lfs diff=lfs merg
47
  code/assets/readme_previews/ssm_legacy_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
48
  code/diffsynth/tokenizer_configs/hunyuan_video/tokenizer_2/tokenizer.json filter=lfs diff=lfs merge=lfs -text
49
  code/diffsynth/tokenizer_configs/kolors/tokenizer/vocab.txt filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  code/assets/readme_previews/ssm_legacy_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
48
  code/diffsynth/tokenizer_configs/hunyuan_video/tokenizer_2/tokenizer.json filter=lfs diff=lfs merge=lfs -text
49
  code/diffsynth/tokenizer_configs/kolors/tokenizer/vocab.txt filter=lfs diff=lfs merge=lfs -text
50
+ code/docs/assets/echo-memory-paper.pdf filter=lfs diff=lfs merge=lfs -text
51
+ code/docs/assets/readme_previews/context_k1_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
52
+ code/docs/assets/readme_previews/context_k5_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
53
+ code/docs/assets/readme_previews/dyn_context_k1_replay.gif filter=lfs diff=lfs merge=lfs -text
54
+ code/docs/assets/readme_previews/dyn_context_k20_replay.gif filter=lfs diff=lfs merge=lfs -text
55
+ code/docs/assets/readme_previews/dyn_context_k5_replay.gif filter=lfs diff=lfs merge=lfs -text
56
+ code/docs/assets/readme_previews/dyn_spatial_memory_replay.gif filter=lfs diff=lfs merge=lfs -text
57
+ code/docs/assets/readme_previews/dyn_ssm_blockwise_replay.gif filter=lfs diff=lfs merge=lfs -text
58
+ code/docs/assets/readme_previews/dyn_ssm_legacy_replay.gif filter=lfs diff=lfs merge=lfs -text
59
+ code/docs/assets/readme_previews/framepack_len_r4_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
60
+ code/docs/assets/readme_previews/spatial_memory_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
61
+ code/docs/assets/readme_previews/ssm_blockwise_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
62
+ code/docs/assets/readme_previews/ssm_legacy_replay_gt.gif filter=lfs diff=lfs merge=lfs -text
code/diffsynth/tokenizer_configs/stable_diffusion_3/tokenizer_3/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
code/diffsynth/tokenizer_configs/stable_diffusion_3/tokenizer_3/tokenizer_config.json ADDED
@@ -0,0 +1,940 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": true,
3
+ "added_tokens_decoder": {
4
+ "0": {
5
+ "content": "<pad>",
6
+ "lstrip": false,
7
+ "normalized": false,
8
+ "rstrip": false,
9
+ "single_word": false,
10
+ "special": true
11
+ },
12
+ "1": {
13
+ "content": "</s>",
14
+ "lstrip": false,
15
+ "normalized": false,
16
+ "rstrip": false,
17
+ "single_word": false,
18
+ "special": true
19
+ },
20
+ "2": {
21
+ "content": "<unk>",
22
+ "lstrip": false,
23
+ "normalized": false,
24
+ "rstrip": false,
25
+ "single_word": false,
26
+ "special": true
27
+ },
28
+ "32000": {
29
+ "content": "<extra_id_99>",
30
+ "lstrip": true,
31
+ "normalized": false,
32
+ "rstrip": true,
33
+ "single_word": false,
34
+ "special": true
35
+ },
36
+ "32001": {
37
+ "content": "<extra_id_98>",
38
+ "lstrip": true,
39
+ "normalized": false,
40
+ "rstrip": true,
41
+ "single_word": false,
42
+ "special": true
43
+ },
44
+ "32002": {
45
+ "content": "<extra_id_97>",
46
+ "lstrip": true,
47
+ "normalized": false,
48
+ "rstrip": true,
49
+ "single_word": false,
50
+ "special": true
51
+ },
52
+ "32003": {
53
+ "content": "<extra_id_96>",
54
+ "lstrip": true,
55
+ "normalized": false,
56
+ "rstrip": true,
57
+ "single_word": false,
58
+ "special": true
59
+ },
60
+ "32004": {
61
+ "content": "<extra_id_95>",
62
+ "lstrip": true,
63
+ "normalized": false,
64
+ "rstrip": true,
65
+ "single_word": false,
66
+ "special": true
67
+ },
68
+ "32005": {
69
+ "content": "<extra_id_94>",
70
+ "lstrip": true,
71
+ "normalized": false,
72
+ "rstrip": true,
73
+ "single_word": false,
74
+ "special": true
75
+ },
76
+ "32006": {
77
+ "content": "<extra_id_93>",
78
+ "lstrip": true,
79
+ "normalized": false,
80
+ "rstrip": true,
81
+ "single_word": false,
82
+ "special": true
83
+ },
84
+ "32007": {
85
+ "content": "<extra_id_92>",
86
+ "lstrip": true,
87
+ "normalized": false,
88
+ "rstrip": true,
89
+ "single_word": false,
90
+ "special": true
91
+ },
92
+ "32008": {
93
+ "content": "<extra_id_91>",
94
+ "lstrip": true,
95
+ "normalized": false,
96
+ "rstrip": true,
97
+ "single_word": false,
98
+ "special": true
99
+ },
100
+ "32009": {
101
+ "content": "<extra_id_90>",
102
+ "lstrip": true,
103
+ "normalized": false,
104
+ "rstrip": true,
105
+ "single_word": false,
106
+ "special": true
107
+ },
108
+ "32010": {
109
+ "content": "<extra_id_89>",
110
+ "lstrip": true,
111
+ "normalized": false,
112
+ "rstrip": true,
113
+ "single_word": false,
114
+ "special": true
115
+ },
116
+ "32011": {
117
+ "content": "<extra_id_88>",
118
+ "lstrip": true,
119
+ "normalized": false,
120
+ "rstrip": true,
121
+ "single_word": false,
122
+ "special": true
123
+ },
124
+ "32012": {
125
+ "content": "<extra_id_87>",
126
+ "lstrip": true,
127
+ "normalized": false,
128
+ "rstrip": true,
129
+ "single_word": false,
130
+ "special": true
131
+ },
132
+ "32013": {
133
+ "content": "<extra_id_86>",
134
+ "lstrip": true,
135
+ "normalized": false,
136
+ "rstrip": true,
137
+ "single_word": false,
138
+ "special": true
139
+ },
140
+ "32014": {
141
+ "content": "<extra_id_85>",
142
+ "lstrip": true,
143
+ "normalized": false,
144
+ "rstrip": true,
145
+ "single_word": false,
146
+ "special": true
147
+ },
148
+ "32015": {
149
+ "content": "<extra_id_84>",
150
+ "lstrip": true,
151
+ "normalized": false,
152
+ "rstrip": true,
153
+ "single_word": false,
154
+ "special": true
155
+ },
156
+ "32016": {
157
+ "content": "<extra_id_83>",
158
+ "lstrip": true,
159
+ "normalized": false,
160
+ "rstrip": true,
161
+ "single_word": false,
162
+ "special": true
163
+ },
164
+ "32017": {
165
+ "content": "<extra_id_82>",
166
+ "lstrip": true,
167
+ "normalized": false,
168
+ "rstrip": true,
169
+ "single_word": false,
170
+ "special": true
171
+ },
172
+ "32018": {
173
+ "content": "<extra_id_81>",
174
+ "lstrip": true,
175
+ "normalized": false,
176
+ "rstrip": true,
177
+ "single_word": false,
178
+ "special": true
179
+ },
180
+ "32019": {
181
+ "content": "<extra_id_80>",
182
+ "lstrip": true,
183
+ "normalized": false,
184
+ "rstrip": true,
185
+ "single_word": false,
186
+ "special": true
187
+ },
188
+ "32020": {
189
+ "content": "<extra_id_79>",
190
+ "lstrip": true,
191
+ "normalized": false,
192
+ "rstrip": true,
193
+ "single_word": false,
194
+ "special": true
195
+ },
196
+ "32021": {
197
+ "content": "<extra_id_78>",
198
+ "lstrip": true,
199
+ "normalized": false,
200
+ "rstrip": true,
201
+ "single_word": false,
202
+ "special": true
203
+ },
204
+ "32022": {
205
+ "content": "<extra_id_77>",
206
+ "lstrip": true,
207
+ "normalized": false,
208
+ "rstrip": true,
209
+ "single_word": false,
210
+ "special": true
211
+ },
212
+ "32023": {
213
+ "content": "<extra_id_76>",
214
+ "lstrip": true,
215
+ "normalized": false,
216
+ "rstrip": true,
217
+ "single_word": false,
218
+ "special": true
219
+ },
220
+ "32024": {
221
+ "content": "<extra_id_75>",
222
+ "lstrip": true,
223
+ "normalized": false,
224
+ "rstrip": true,
225
+ "single_word": false,
226
+ "special": true
227
+ },
228
+ "32025": {
229
+ "content": "<extra_id_74>",
230
+ "lstrip": true,
231
+ "normalized": false,
232
+ "rstrip": true,
233
+ "single_word": false,
234
+ "special": true
235
+ },
236
+ "32026": {
237
+ "content": "<extra_id_73>",
238
+ "lstrip": true,
239
+ "normalized": false,
240
+ "rstrip": true,
241
+ "single_word": false,
242
+ "special": true
243
+ },
244
+ "32027": {
245
+ "content": "<extra_id_72>",
246
+ "lstrip": true,
247
+ "normalized": false,
248
+ "rstrip": true,
249
+ "single_word": false,
250
+ "special": true
251
+ },
252
+ "32028": {
253
+ "content": "<extra_id_71>",
254
+ "lstrip": true,
255
+ "normalized": false,
256
+ "rstrip": true,
257
+ "single_word": false,
258
+ "special": true
259
+ },
260
+ "32029": {
261
+ "content": "<extra_id_70>",
262
+ "lstrip": true,
263
+ "normalized": false,
264
+ "rstrip": true,
265
+ "single_word": false,
266
+ "special": true
267
+ },
268
+ "32030": {
269
+ "content": "<extra_id_69>",
270
+ "lstrip": true,
271
+ "normalized": false,
272
+ "rstrip": true,
273
+ "single_word": false,
274
+ "special": true
275
+ },
276
+ "32031": {
277
+ "content": "<extra_id_68>",
278
+ "lstrip": true,
279
+ "normalized": false,
280
+ "rstrip": true,
281
+ "single_word": false,
282
+ "special": true
283
+ },
284
+ "32032": {
285
+ "content": "<extra_id_67>",
286
+ "lstrip": true,
287
+ "normalized": false,
288
+ "rstrip": true,
289
+ "single_word": false,
290
+ "special": true
291
+ },
292
+ "32033": {
293
+ "content": "<extra_id_66>",
294
+ "lstrip": true,
295
+ "normalized": false,
296
+ "rstrip": true,
297
+ "single_word": false,
298
+ "special": true
299
+ },
300
+ "32034": {
301
+ "content": "<extra_id_65>",
302
+ "lstrip": true,
303
+ "normalized": false,
304
+ "rstrip": true,
305
+ "single_word": false,
306
+ "special": true
307
+ },
308
+ "32035": {
309
+ "content": "<extra_id_64>",
310
+ "lstrip": true,
311
+ "normalized": false,
312
+ "rstrip": true,
313
+ "single_word": false,
314
+ "special": true
315
+ },
316
+ "32036": {
317
+ "content": "<extra_id_63>",
318
+ "lstrip": true,
319
+ "normalized": false,
320
+ "rstrip": true,
321
+ "single_word": false,
322
+ "special": true
323
+ },
324
+ "32037": {
325
+ "content": "<extra_id_62>",
326
+ "lstrip": true,
327
+ "normalized": false,
328
+ "rstrip": true,
329
+ "single_word": false,
330
+ "special": true
331
+ },
332
+ "32038": {
333
+ "content": "<extra_id_61>",
334
+ "lstrip": true,
335
+ "normalized": false,
336
+ "rstrip": true,
337
+ "single_word": false,
338
+ "special": true
339
+ },
340
+ "32039": {
341
+ "content": "<extra_id_60>",
342
+ "lstrip": true,
343
+ "normalized": false,
344
+ "rstrip": true,
345
+ "single_word": false,
346
+ "special": true
347
+ },
348
+ "32040": {
349
+ "content": "<extra_id_59>",
350
+ "lstrip": true,
351
+ "normalized": false,
352
+ "rstrip": true,
353
+ "single_word": false,
354
+ "special": true
355
+ },
356
+ "32041": {
357
+ "content": "<extra_id_58>",
358
+ "lstrip": true,
359
+ "normalized": false,
360
+ "rstrip": true,
361
+ "single_word": false,
362
+ "special": true
363
+ },
364
+ "32042": {
365
+ "content": "<extra_id_57>",
366
+ "lstrip": true,
367
+ "normalized": false,
368
+ "rstrip": true,
369
+ "single_word": false,
370
+ "special": true
371
+ },
372
+ "32043": {
373
+ "content": "<extra_id_56>",
374
+ "lstrip": true,
375
+ "normalized": false,
376
+ "rstrip": true,
377
+ "single_word": false,
378
+ "special": true
379
+ },
380
+ "32044": {
381
+ "content": "<extra_id_55>",
382
+ "lstrip": true,
383
+ "normalized": false,
384
+ "rstrip": true,
385
+ "single_word": false,
386
+ "special": true
387
+ },
388
+ "32045": {
389
+ "content": "<extra_id_54>",
390
+ "lstrip": true,
391
+ "normalized": false,
392
+ "rstrip": true,
393
+ "single_word": false,
394
+ "special": true
395
+ },
396
+ "32046": {
397
+ "content": "<extra_id_53>",
398
+ "lstrip": true,
399
+ "normalized": false,
400
+ "rstrip": true,
401
+ "single_word": false,
402
+ "special": true
403
+ },
404
+ "32047": {
405
+ "content": "<extra_id_52>",
406
+ "lstrip": true,
407
+ "normalized": false,
408
+ "rstrip": true,
409
+ "single_word": false,
410
+ "special": true
411
+ },
412
+ "32048": {
413
+ "content": "<extra_id_51>",
414
+ "lstrip": true,
415
+ "normalized": false,
416
+ "rstrip": true,
417
+ "single_word": false,
418
+ "special": true
419
+ },
420
+ "32049": {
421
+ "content": "<extra_id_50>",
422
+ "lstrip": true,
423
+ "normalized": false,
424
+ "rstrip": true,
425
+ "single_word": false,
426
+ "special": true
427
+ },
428
+ "32050": {
429
+ "content": "<extra_id_49>",
430
+ "lstrip": true,
431
+ "normalized": false,
432
+ "rstrip": true,
433
+ "single_word": false,
434
+ "special": true
435
+ },
436
+ "32051": {
437
+ "content": "<extra_id_48>",
438
+ "lstrip": true,
439
+ "normalized": false,
440
+ "rstrip": true,
441
+ "single_word": false,
442
+ "special": true
443
+ },
444
+ "32052": {
445
+ "content": "<extra_id_47>",
446
+ "lstrip": true,
447
+ "normalized": false,
448
+ "rstrip": true,
449
+ "single_word": false,
450
+ "special": true
451
+ },
452
+ "32053": {
453
+ "content": "<extra_id_46>",
454
+ "lstrip": true,
455
+ "normalized": false,
456
+ "rstrip": true,
457
+ "single_word": false,
458
+ "special": true
459
+ },
460
+ "32054": {
461
+ "content": "<extra_id_45>",
462
+ "lstrip": true,
463
+ "normalized": false,
464
+ "rstrip": true,
465
+ "single_word": false,
466
+ "special": true
467
+ },
468
+ "32055": {
469
+ "content": "<extra_id_44>",
470
+ "lstrip": true,
471
+ "normalized": false,
472
+ "rstrip": true,
473
+ "single_word": false,
474
+ "special": true
475
+ },
476
+ "32056": {
477
+ "content": "<extra_id_43>",
478
+ "lstrip": true,
479
+ "normalized": false,
480
+ "rstrip": true,
481
+ "single_word": false,
482
+ "special": true
483
+ },
484
+ "32057": {
485
+ "content": "<extra_id_42>",
486
+ "lstrip": true,
487
+ "normalized": false,
488
+ "rstrip": true,
489
+ "single_word": false,
490
+ "special": true
491
+ },
492
+ "32058": {
493
+ "content": "<extra_id_41>",
494
+ "lstrip": true,
495
+ "normalized": false,
496
+ "rstrip": true,
497
+ "single_word": false,
498
+ "special": true
499
+ },
500
+ "32059": {
501
+ "content": "<extra_id_40>",
502
+ "lstrip": true,
503
+ "normalized": false,
504
+ "rstrip": true,
505
+ "single_word": false,
506
+ "special": true
507
+ },
508
+ "32060": {
509
+ "content": "<extra_id_39>",
510
+ "lstrip": true,
511
+ "normalized": false,
512
+ "rstrip": true,
513
+ "single_word": false,
514
+ "special": true
515
+ },
516
+ "32061": {
517
+ "content": "<extra_id_38>",
518
+ "lstrip": true,
519
+ "normalized": false,
520
+ "rstrip": true,
521
+ "single_word": false,
522
+ "special": true
523
+ },
524
+ "32062": {
525
+ "content": "<extra_id_37>",
526
+ "lstrip": true,
527
+ "normalized": false,
528
+ "rstrip": true,
529
+ "single_word": false,
530
+ "special": true
531
+ },
532
+ "32063": {
533
+ "content": "<extra_id_36>",
534
+ "lstrip": true,
535
+ "normalized": false,
536
+ "rstrip": true,
537
+ "single_word": false,
538
+ "special": true
539
+ },
540
+ "32064": {
541
+ "content": "<extra_id_35>",
542
+ "lstrip": true,
543
+ "normalized": false,
544
+ "rstrip": true,
545
+ "single_word": false,
546
+ "special": true
547
+ },
548
+ "32065": {
549
+ "content": "<extra_id_34>",
550
+ "lstrip": true,
551
+ "normalized": false,
552
+ "rstrip": true,
553
+ "single_word": false,
554
+ "special": true
555
+ },
556
+ "32066": {
557
+ "content": "<extra_id_33>",
558
+ "lstrip": true,
559
+ "normalized": false,
560
+ "rstrip": true,
561
+ "single_word": false,
562
+ "special": true
563
+ },
564
+ "32067": {
565
+ "content": "<extra_id_32>",
566
+ "lstrip": true,
567
+ "normalized": false,
568
+ "rstrip": true,
569
+ "single_word": false,
570
+ "special": true
571
+ },
572
+ "32068": {
573
+ "content": "<extra_id_31>",
574
+ "lstrip": true,
575
+ "normalized": false,
576
+ "rstrip": true,
577
+ "single_word": false,
578
+ "special": true
579
+ },
580
+ "32069": {
581
+ "content": "<extra_id_30>",
582
+ "lstrip": true,
583
+ "normalized": false,
584
+ "rstrip": true,
585
+ "single_word": false,
586
+ "special": true
587
+ },
588
+ "32070": {
589
+ "content": "<extra_id_29>",
590
+ "lstrip": true,
591
+ "normalized": false,
592
+ "rstrip": true,
593
+ "single_word": false,
594
+ "special": true
595
+ },
596
+ "32071": {
597
+ "content": "<extra_id_28>",
598
+ "lstrip": true,
599
+ "normalized": false,
600
+ "rstrip": true,
601
+ "single_word": false,
602
+ "special": true
603
+ },
604
+ "32072": {
605
+ "content": "<extra_id_27>",
606
+ "lstrip": true,
607
+ "normalized": false,
608
+ "rstrip": true,
609
+ "single_word": false,
610
+ "special": true
611
+ },
612
+ "32073": {
613
+ "content": "<extra_id_26>",
614
+ "lstrip": true,
615
+ "normalized": false,
616
+ "rstrip": true,
617
+ "single_word": false,
618
+ "special": true
619
+ },
620
+ "32074": {
621
+ "content": "<extra_id_25>",
622
+ "lstrip": true,
623
+ "normalized": false,
624
+ "rstrip": true,
625
+ "single_word": false,
626
+ "special": true
627
+ },
628
+ "32075": {
629
+ "content": "<extra_id_24>",
630
+ "lstrip": true,
631
+ "normalized": false,
632
+ "rstrip": true,
633
+ "single_word": false,
634
+ "special": true
635
+ },
636
+ "32076": {
637
+ "content": "<extra_id_23>",
638
+ "lstrip": true,
639
+ "normalized": false,
640
+ "rstrip": true,
641
+ "single_word": false,
642
+ "special": true
643
+ },
644
+ "32077": {
645
+ "content": "<extra_id_22>",
646
+ "lstrip": true,
647
+ "normalized": false,
648
+ "rstrip": true,
649
+ "single_word": false,
650
+ "special": true
651
+ },
652
+ "32078": {
653
+ "content": "<extra_id_21>",
654
+ "lstrip": true,
655
+ "normalized": false,
656
+ "rstrip": true,
657
+ "single_word": false,
658
+ "special": true
659
+ },
660
+ "32079": {
661
+ "content": "<extra_id_20>",
662
+ "lstrip": true,
663
+ "normalized": false,
664
+ "rstrip": true,
665
+ "single_word": false,
666
+ "special": true
667
+ },
668
+ "32080": {
669
+ "content": "<extra_id_19>",
670
+ "lstrip": true,
671
+ "normalized": false,
672
+ "rstrip": true,
673
+ "single_word": false,
674
+ "special": true
675
+ },
676
+ "32081": {
677
+ "content": "<extra_id_18>",
678
+ "lstrip": true,
679
+ "normalized": false,
680
+ "rstrip": true,
681
+ "single_word": false,
682
+ "special": true
683
+ },
684
+ "32082": {
685
+ "content": "<extra_id_17>",
686
+ "lstrip": true,
687
+ "normalized": false,
688
+ "rstrip": true,
689
+ "single_word": false,
690
+ "special": true
691
+ },
692
+ "32083": {
693
+ "content": "<extra_id_16>",
694
+ "lstrip": true,
695
+ "normalized": false,
696
+ "rstrip": true,
697
+ "single_word": false,
698
+ "special": true
699
+ },
700
+ "32084": {
701
+ "content": "<extra_id_15>",
702
+ "lstrip": true,
703
+ "normalized": false,
704
+ "rstrip": true,
705
+ "single_word": false,
706
+ "special": true
707
+ },
708
+ "32085": {
709
+ "content": "<extra_id_14>",
710
+ "lstrip": true,
711
+ "normalized": false,
712
+ "rstrip": true,
713
+ "single_word": false,
714
+ "special": true
715
+ },
716
+ "32086": {
717
+ "content": "<extra_id_13>",
718
+ "lstrip": true,
719
+ "normalized": false,
720
+ "rstrip": true,
721
+ "single_word": false,
722
+ "special": true
723
+ },
724
+ "32087": {
725
+ "content": "<extra_id_12>",
726
+ "lstrip": true,
727
+ "normalized": false,
728
+ "rstrip": true,
729
+ "single_word": false,
730
+ "special": true
731
+ },
732
+ "32088": {
733
+ "content": "<extra_id_11>",
734
+ "lstrip": true,
735
+ "normalized": false,
736
+ "rstrip": true,
737
+ "single_word": false,
738
+ "special": true
739
+ },
740
+ "32089": {
741
+ "content": "<extra_id_10>",
742
+ "lstrip": true,
743
+ "normalized": false,
744
+ "rstrip": true,
745
+ "single_word": false,
746
+ "special": true
747
+ },
748
+ "32090": {
749
+ "content": "<extra_id_9>",
750
+ "lstrip": true,
751
+ "normalized": false,
752
+ "rstrip": true,
753
+ "single_word": false,
754
+ "special": true
755
+ },
756
+ "32091": {
757
+ "content": "<extra_id_8>",
758
+ "lstrip": true,
759
+ "normalized": false,
760
+ "rstrip": true,
761
+ "single_word": false,
762
+ "special": true
763
+ },
764
+ "32092": {
765
+ "content": "<extra_id_7>",
766
+ "lstrip": true,
767
+ "normalized": false,
768
+ "rstrip": true,
769
+ "single_word": false,
770
+ "special": true
771
+ },
772
+ "32093": {
773
+ "content": "<extra_id_6>",
774
+ "lstrip": true,
775
+ "normalized": false,
776
+ "rstrip": true,
777
+ "single_word": false,
778
+ "special": true
779
+ },
780
+ "32094": {
781
+ "content": "<extra_id_5>",
782
+ "lstrip": true,
783
+ "normalized": false,
784
+ "rstrip": true,
785
+ "single_word": false,
786
+ "special": true
787
+ },
788
+ "32095": {
789
+ "content": "<extra_id_4>",
790
+ "lstrip": true,
791
+ "normalized": false,
792
+ "rstrip": true,
793
+ "single_word": false,
794
+ "special": true
795
+ },
796
+ "32096": {
797
+ "content": "<extra_id_3>",
798
+ "lstrip": true,
799
+ "normalized": false,
800
+ "rstrip": true,
801
+ "single_word": false,
802
+ "special": true
803
+ },
804
+ "32097": {
805
+ "content": "<extra_id_2>",
806
+ "lstrip": true,
807
+ "normalized": false,
808
+ "rstrip": true,
809
+ "single_word": false,
810
+ "special": true
811
+ },
812
+ "32098": {
813
+ "content": "<extra_id_1>",
814
+ "lstrip": true,
815
+ "normalized": false,
816
+ "rstrip": true,
817
+ "single_word": false,
818
+ "special": true
819
+ },
820
+ "32099": {
821
+ "content": "<extra_id_0>",
822
+ "lstrip": true,
823
+ "normalized": false,
824
+ "rstrip": true,
825
+ "single_word": false,
826
+ "special": true
827
+ }
828
+ },
829
+ "additional_special_tokens": [
830
+ "<extra_id_0>",
831
+ "<extra_id_1>",
832
+ "<extra_id_2>",
833
+ "<extra_id_3>",
834
+ "<extra_id_4>",
835
+ "<extra_id_5>",
836
+ "<extra_id_6>",
837
+ "<extra_id_7>",
838
+ "<extra_id_8>",
839
+ "<extra_id_9>",
840
+ "<extra_id_10>",
841
+ "<extra_id_11>",
842
+ "<extra_id_12>",
843
+ "<extra_id_13>",
844
+ "<extra_id_14>",
845
+ "<extra_id_15>",
846
+ "<extra_id_16>",
847
+ "<extra_id_17>",
848
+ "<extra_id_18>",
849
+ "<extra_id_19>",
850
+ "<extra_id_20>",
851
+ "<extra_id_21>",
852
+ "<extra_id_22>",
853
+ "<extra_id_23>",
854
+ "<extra_id_24>",
855
+ "<extra_id_25>",
856
+ "<extra_id_26>",
857
+ "<extra_id_27>",
858
+ "<extra_id_28>",
859
+ "<extra_id_29>",
860
+ "<extra_id_30>",
861
+ "<extra_id_31>",
862
+ "<extra_id_32>",
863
+ "<extra_id_33>",
864
+ "<extra_id_34>",
865
+ "<extra_id_35>",
866
+ "<extra_id_36>",
867
+ "<extra_id_37>",
868
+ "<extra_id_38>",
869
+ "<extra_id_39>",
870
+ "<extra_id_40>",
871
+ "<extra_id_41>",
872
+ "<extra_id_42>",
873
+ "<extra_id_43>",
874
+ "<extra_id_44>",
875
+ "<extra_id_45>",
876
+ "<extra_id_46>",
877
+ "<extra_id_47>",
878
+ "<extra_id_48>",
879
+ "<extra_id_49>",
880
+ "<extra_id_50>",
881
+ "<extra_id_51>",
882
+ "<extra_id_52>",
883
+ "<extra_id_53>",
884
+ "<extra_id_54>",
885
+ "<extra_id_55>",
886
+ "<extra_id_56>",
887
+ "<extra_id_57>",
888
+ "<extra_id_58>",
889
+ "<extra_id_59>",
890
+ "<extra_id_60>",
891
+ "<extra_id_61>",
892
+ "<extra_id_62>",
893
+ "<extra_id_63>",
894
+ "<extra_id_64>",
895
+ "<extra_id_65>",
896
+ "<extra_id_66>",
897
+ "<extra_id_67>",
898
+ "<extra_id_68>",
899
+ "<extra_id_69>",
900
+ "<extra_id_70>",
901
+ "<extra_id_71>",
902
+ "<extra_id_72>",
903
+ "<extra_id_73>",
904
+ "<extra_id_74>",
905
+ "<extra_id_75>",
906
+ "<extra_id_76>",
907
+ "<extra_id_77>",
908
+ "<extra_id_78>",
909
+ "<extra_id_79>",
910
+ "<extra_id_80>",
911
+ "<extra_id_81>",
912
+ "<extra_id_82>",
913
+ "<extra_id_83>",
914
+ "<extra_id_84>",
915
+ "<extra_id_85>",
916
+ "<extra_id_86>",
917
+ "<extra_id_87>",
918
+ "<extra_id_88>",
919
+ "<extra_id_89>",
920
+ "<extra_id_90>",
921
+ "<extra_id_91>",
922
+ "<extra_id_92>",
923
+ "<extra_id_93>",
924
+ "<extra_id_94>",
925
+ "<extra_id_95>",
926
+ "<extra_id_96>",
927
+ "<extra_id_97>",
928
+ "<extra_id_98>",
929
+ "<extra_id_99>"
930
+ ],
931
+ "clean_up_tokenization_spaces": true,
932
+ "eos_token": "</s>",
933
+ "extra_ids": 100,
934
+ "legacy": true,
935
+ "model_max_length": 512,
936
+ "pad_token": "<pad>",
937
+ "sp_model_kwargs": {},
938
+ "tokenizer_class": "T5Tokenizer",
939
+ "unk_token": "<unk>"
940
+ }
code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/merges.txt ADDED
The diff for this file is too large to render. See raw diff
 
code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/special_tokens_map.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": {
3
+ "content": "<|startoftext|>",
4
+ "lstrip": false,
5
+ "normalized": true,
6
+ "rstrip": false,
7
+ "single_word": false
8
+ },
9
+ "eos_token": {
10
+ "content": "<|endoftext|>",
11
+ "lstrip": false,
12
+ "normalized": true,
13
+ "rstrip": false,
14
+ "single_word": false
15
+ },
16
+ "pad_token": "!",
17
+ "unk_token": {
18
+ "content": "<|endoftext|>",
19
+ "lstrip": false,
20
+ "normalized": true,
21
+ "rstrip": false,
22
+ "single_word": false
23
+ }
24
+ }
code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/tokenizer_config.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "added_tokens_decoder": {
4
+ "0": {
5
+ "content": "!",
6
+ "lstrip": false,
7
+ "normalized": false,
8
+ "rstrip": false,
9
+ "single_word": false,
10
+ "special": true
11
+ },
12
+ "49406": {
13
+ "content": "<|startoftext|>",
14
+ "lstrip": false,
15
+ "normalized": true,
16
+ "rstrip": false,
17
+ "single_word": false,
18
+ "special": true
19
+ },
20
+ "49407": {
21
+ "content": "<|endoftext|>",
22
+ "lstrip": false,
23
+ "normalized": true,
24
+ "rstrip": false,
25
+ "single_word": false,
26
+ "special": true
27
+ }
28
+ },
29
+ "bos_token": "<|startoftext|>",
30
+ "clean_up_tokenization_spaces": true,
31
+ "do_lower_case": true,
32
+ "eos_token": "<|endoftext|>",
33
+ "errors": "replace",
34
+ "model_max_length": 77,
35
+ "pad_token": "!",
36
+ "tokenizer_class": "CLIPTokenizer",
37
+ "unk_token": "<|endoftext|>"
38
+ }
code/diffsynth/tokenizer_configs/stable_diffusion_xl/tokenizer_2/vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
code/diffsynth/trainers/__init__.py ADDED
File without changes
code/diffsynth/trainers/latent_dataset.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LatentDataset: Load precomputed ctx and target latents for Context-as-Memory dataset.
3
+
4
+ Use after running precompute_ctx_target_latents.py. Returns samples with:
5
+ - context_latents: (1, C, K, H//8, W//8) - 1 latent per context frame
6
+ - target_latents: (1, C, T, H//8, W//8) - 1 latent per 4 target frames
7
+ - prompt, video_name, start_frame, end_frame, actions
8
+
9
+ Compatible with training that uses precomputed latents instead of encoding on the fly.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import warnings
15
+
16
+ import torch
17
+
18
+
19
+ class LatentDataset(torch.utils.data.Dataset):
20
+ """
21
+ Dataset that loads precomputed ctx and target latents.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ latent_dir,
27
+ metadata_path=None,
28
+ action_base_path=None,
29
+ repeat=1,
30
+ num_frames=81,
31
+ context_frames=5,
32
+ target_frames_per_latent=4,
33
+ ):
34
+ """
35
+ Args:
36
+ latent_dir: Directory containing ctx_latents/ and target_latents/ subdirs.
37
+ metadata_path: Optional. If provided, used to get total_samples and validate.
38
+ action_base_path: Base path for action JSON files (for loading actions if not in .pt).
39
+ repeat: Dataset repeat factor.
40
+ num_frames: Expected num_frames per segment.
41
+ context_frames: Number of context frames (K).
42
+ target_frames_per_latent: Target: 1 latent per N frames.
43
+ """
44
+ self.latent_dir = latent_dir
45
+ self.ctx_dir = os.path.join(latent_dir, "ctx_latents")
46
+ self.target_dir = os.path.join(latent_dir, "target_latents")
47
+ self.action_base_path = action_base_path or latent_dir
48
+ self.repeat = repeat
49
+ self.num_frames = num_frames
50
+ self.context_frames = context_frames
51
+ self.target_frames_per_latent = target_frames_per_latent
52
+
53
+ # Infer valid indices from existing files (both ctx and target must exist)
54
+ self._indices = []
55
+ if os.path.isdir(self.ctx_dir) and os.path.isdir(self.target_dir):
56
+ ctx_files = {f.replace(".pt", "") for f in os.listdir(self.ctx_dir) if f.endswith(".pt")}
57
+ target_files = {f.replace(".pt", "") for f in os.listdir(self.target_dir) if f.endswith(".pt")}
58
+ common = sorted([int(x) for x in ctx_files & target_files])
59
+ self._indices = common
60
+ if not self._indices:
61
+ meta_path = os.path.join(latent_dir, "metadata_precompute.json")
62
+ if os.path.isfile(meta_path):
63
+ with open(meta_path) as f:
64
+ meta = json.load(f)
65
+ self._total = meta.get("total_samples", 0)
66
+ self._indices = list(range(self._total))
67
+ else:
68
+ self._total = 0
69
+ else:
70
+ self._total = len(self._indices)
71
+
72
+ def __len__(self):
73
+ return self._total * self.repeat
74
+
75
+ def __getitem__(self, idx):
76
+ real_idx = idx % self._total
77
+ if self._indices is not None:
78
+ real_idx = self._indices[real_idx]
79
+
80
+ ctx_path = os.path.join(self.ctx_dir, f"{real_idx:08d}.pt")
81
+ target_path = os.path.join(self.target_dir, f"{real_idx:08d}.pt")
82
+
83
+ if not os.path.isfile(ctx_path) or not os.path.isfile(target_path):
84
+ warnings.warn(f"Latent files not found for idx {real_idx}. Returning None.")
85
+ return None
86
+
87
+ ctx_data = torch.load(ctx_path, map_location="cpu", weights_only=True)
88
+ target_data = torch.load(target_path, map_location="cpu", weights_only=True)
89
+
90
+ ctx_latent = ctx_data["latent"]
91
+ target_latent = target_data["latent"]
92
+
93
+ # Ensure batch dimension: (C, K, H, W) -> (1, C, K, H, W)
94
+ if ctx_latent.dim() == 4:
95
+ ctx_latent = ctx_latent.unsqueeze(0)
96
+ if target_latent.dim() == 4:
97
+ target_latent = target_latent.unsqueeze(0)
98
+
99
+ out = {
100
+ "context_latents": ctx_latent,
101
+ "target_latents": target_latent,
102
+ "prompt": ctx_data.get("prompt", ""),
103
+ "video_name": ctx_data.get("video_name"),
104
+ "start_frame": ctx_data.get("start_frame"),
105
+ "end_frame": ctx_data.get("end_frame"),
106
+ }
107
+ if "actions" in ctx_data and ctx_data["actions"] is not None:
108
+ out["actions"] = ctx_data["actions"]
109
+ elif "actions" in target_data and target_data["actions"] is not None:
110
+ out["actions"] = target_data["actions"]
111
+
112
+ return out
113
+
114
+
115
+ def get_latent_dataset_args(latent_dir, action_base_path=None, **kwargs):
116
+ """Build argparse.Namespace for LatentDataset from precompute metadata."""
117
+ meta_path = os.path.join(latent_dir, "metadata_precompute.json")
118
+ if not os.path.isfile(meta_path):
119
+ return None
120
+ with open(meta_path) as f:
121
+ meta = json.load(f)
122
+ from argparse import Namespace
123
+ return Namespace(
124
+ latent_dir=latent_dir,
125
+ action_base_path=action_base_path or meta.get("dataset_base_path", latent_dir),
126
+ num_frames=meta.get("num_frames", 81),
127
+ context_frames=meta.get("context_frames", 5),
128
+ target_frames_per_latent=meta.get("target_frames_per_latent", 4),
129
+ **kwargs,
130
+ )
code/diffsynth/trainers/text_to_image.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import lightning as pl
2
+ from peft import LoraConfig, inject_adapter_in_model
3
+ import torch, os
4
+ from ..data.simple_text_image import TextImageDataset
5
+ from modelscope.hub.api import HubApi
6
+ from ..models.utils import load_state_dict
7
+
8
+
9
+
10
+ class LightningModelForT2ILoRA(pl.LightningModule):
11
+ def __init__(
12
+ self,
13
+ learning_rate=1e-4,
14
+ use_gradient_checkpointing=True,
15
+ state_dict_converter=None,
16
+ ):
17
+ super().__init__()
18
+ # Set parameters
19
+ self.learning_rate = learning_rate
20
+ self.use_gradient_checkpointing = use_gradient_checkpointing
21
+ self.state_dict_converter = state_dict_converter
22
+ self.lora_alpha = None
23
+
24
+
25
+ def load_models(self):
26
+ # This function is implemented in other modules
27
+ self.pipe = None
28
+
29
+
30
+ def freeze_parameters(self):
31
+ # Freeze parameters
32
+ self.pipe.requires_grad_(False)
33
+ self.pipe.eval()
34
+ self.pipe.denoising_model().train()
35
+
36
+
37
+ def add_lora_to_model(self, model, lora_rank=4, lora_alpha=4, lora_target_modules="to_q,to_k,to_v,to_out", init_lora_weights="gaussian", pretrained_lora_path=None, state_dict_converter=None):
38
+ # Add LoRA to UNet
39
+ self.lora_alpha = lora_alpha
40
+ if init_lora_weights == "kaiming":
41
+ init_lora_weights = True
42
+
43
+ lora_config = LoraConfig(
44
+ r=lora_rank,
45
+ lora_alpha=lora_alpha,
46
+ init_lora_weights=init_lora_weights,
47
+ target_modules=lora_target_modules.split(","),
48
+ )
49
+ model = inject_adapter_in_model(lora_config, model)
50
+ for param in model.parameters():
51
+ # Upcast LoRA parameters into fp32
52
+ if param.requires_grad:
53
+ param.data = param.to(torch.float32)
54
+
55
+ # Lora pretrained lora weights
56
+ if pretrained_lora_path is not None:
57
+ state_dict = load_state_dict(pretrained_lora_path)
58
+ if state_dict_converter is not None:
59
+ state_dict = state_dict_converter(state_dict)
60
+ missing_keys, unexpected_keys = model.load_state_dict(state_dict, strict=False)
61
+ all_keys = [i for i, _ in model.named_parameters()]
62
+ num_updated_keys = len(all_keys) - len(missing_keys)
63
+ num_unexpected_keys = len(unexpected_keys)
64
+ print(f"{num_updated_keys} parameters are loaded from {pretrained_lora_path}. {num_unexpected_keys} parameters are unexpected.")
65
+
66
+
67
+ def training_step(self, batch, batch_idx):
68
+ # Data
69
+ text, image = batch["text"], batch["image"]
70
+
71
+ # Prepare input parameters
72
+ self.pipe.device = self.device
73
+ prompt_emb = self.pipe.encode_prompt(text, positive=True)
74
+ if "latents" in batch:
75
+ latents = batch["latents"].to(dtype=self.pipe.torch_dtype, device=self.device)
76
+ else:
77
+ latents = self.pipe.vae_encoder(image.to(dtype=self.pipe.torch_dtype, device=self.device))
78
+ noise = torch.randn_like(latents)
79
+ timestep_id = torch.randint(0, self.pipe.scheduler.num_train_timesteps, (1,))
80
+ timestep = self.pipe.scheduler.timesteps[timestep_id].to(self.device)
81
+ extra_input = self.pipe.prepare_extra_input(latents)
82
+ noisy_latents = self.pipe.scheduler.add_noise(latents, noise, timestep)
83
+ training_target = self.pipe.scheduler.training_target(latents, noise, timestep)
84
+
85
+ # Compute loss
86
+ noise_pred = self.pipe.denoising_model()(
87
+ noisy_latents, timestep=timestep, **prompt_emb, **extra_input,
88
+ use_gradient_checkpointing=self.use_gradient_checkpointing
89
+ )
90
+ loss = torch.nn.functional.mse_loss(noise_pred.float(), training_target.float())
91
+ loss = loss * self.pipe.scheduler.training_weight(timestep)
92
+
93
+ # Record log
94
+ self.log("train_loss", loss, prog_bar=True)
95
+ return loss
96
+
97
+
98
+ def configure_optimizers(self):
99
+ trainable_modules = filter(lambda p: p.requires_grad, self.pipe.denoising_model().parameters())
100
+ optimizer = torch.optim.AdamW(trainable_modules, lr=self.learning_rate)
101
+ return optimizer
102
+
103
+
104
+ def on_save_checkpoint(self, checkpoint):
105
+ checkpoint.clear()
106
+ trainable_param_names = list(filter(lambda named_param: named_param[1].requires_grad, self.pipe.denoising_model().named_parameters()))
107
+ trainable_param_names = set([named_param[0] for named_param in trainable_param_names])
108
+ state_dict = self.pipe.denoising_model().state_dict()
109
+ lora_state_dict = {}
110
+ for name, param in state_dict.items():
111
+ if name in trainable_param_names:
112
+ lora_state_dict[name] = param
113
+ if self.state_dict_converter is not None:
114
+ lora_state_dict = self.state_dict_converter(lora_state_dict, alpha=self.lora_alpha)
115
+ checkpoint.update(lora_state_dict)
116
+
117
+
118
+
119
+ def add_general_parsers(parser):
120
+ parser.add_argument(
121
+ "--dataset_path",
122
+ type=str,
123
+ default=None,
124
+ required=True,
125
+ help="The path of the Dataset.",
126
+ )
127
+ parser.add_argument(
128
+ "--output_path",
129
+ type=str,
130
+ default="./",
131
+ help="Path to save the model.",
132
+ )
133
+ parser.add_argument(
134
+ "--steps_per_epoch",
135
+ type=int,
136
+ default=500,
137
+ help="Number of steps per epoch.",
138
+ )
139
+ parser.add_argument(
140
+ "--height",
141
+ type=int,
142
+ default=1024,
143
+ help="Image height.",
144
+ )
145
+ parser.add_argument(
146
+ "--width",
147
+ type=int,
148
+ default=1024,
149
+ help="Image width.",
150
+ )
151
+ parser.add_argument(
152
+ "--center_crop",
153
+ default=False,
154
+ action="store_true",
155
+ help=(
156
+ "Whether to center crop the input images to the resolution. If not set, the images will be randomly"
157
+ " cropped. The images will be resized to the resolution first before cropping."
158
+ ),
159
+ )
160
+ parser.add_argument(
161
+ "--random_flip",
162
+ default=False,
163
+ action="store_true",
164
+ help="Whether to randomly flip images horizontally",
165
+ )
166
+ parser.add_argument(
167
+ "--batch_size",
168
+ type=int,
169
+ default=1,
170
+ help="Batch size (per device) for the training dataloader.",
171
+ )
172
+ parser.add_argument(
173
+ "--dataloader_num_workers",
174
+ type=int,
175
+ default=0,
176
+ help="Number of subprocesses to use for data loading. 0 means that the data will be loaded in the main process.",
177
+ )
178
+ parser.add_argument(
179
+ "--precision",
180
+ type=str,
181
+ default="16-mixed",
182
+ choices=["32", "16", "16-mixed", "bf16"],
183
+ help="Training precision",
184
+ )
185
+ parser.add_argument(
186
+ "--learning_rate",
187
+ type=float,
188
+ default=1e-4,
189
+ help="Learning rate.",
190
+ )
191
+ parser.add_argument(
192
+ "--lora_rank",
193
+ type=int,
194
+ default=4,
195
+ help="The dimension of the LoRA update matrices.",
196
+ )
197
+ parser.add_argument(
198
+ "--lora_alpha",
199
+ type=float,
200
+ default=4.0,
201
+ help="The weight of the LoRA update matrices.",
202
+ )
203
+ parser.add_argument(
204
+ "--init_lora_weights",
205
+ type=str,
206
+ default="kaiming",
207
+ choices=["gaussian", "kaiming"],
208
+ help="The initializing method of LoRA weight.",
209
+ )
210
+ parser.add_argument(
211
+ "--use_gradient_checkpointing",
212
+ default=False,
213
+ action="store_true",
214
+ help="Whether to use gradient checkpointing.",
215
+ )
216
+ parser.add_argument(
217
+ "--accumulate_grad_batches",
218
+ type=int,
219
+ default=1,
220
+ help="The number of batches in gradient accumulation.",
221
+ )
222
+ parser.add_argument(
223
+ "--training_strategy",
224
+ type=str,
225
+ default="auto",
226
+ choices=["auto", "deepspeed_stage_1", "deepspeed_stage_2", "deepspeed_stage_3"],
227
+ help="Training strategy",
228
+ )
229
+ parser.add_argument(
230
+ "--max_epochs",
231
+ type=int,
232
+ default=1,
233
+ help="Number of epochs.",
234
+ )
235
+ parser.add_argument(
236
+ "--modelscope_model_id",
237
+ type=str,
238
+ default=None,
239
+ help="Model ID on ModelScope (https://www.modelscope.cn/). The model will be uploaded to ModelScope automatically if you provide a Model ID.",
240
+ )
241
+ parser.add_argument(
242
+ "--modelscope_access_token",
243
+ type=str,
244
+ default=None,
245
+ help="Access key on ModelScope (https://www.modelscope.cn/). Required if you want to upload the model to ModelScope.",
246
+ )
247
+ parser.add_argument(
248
+ "--pretrained_lora_path",
249
+ type=str,
250
+ default=None,
251
+ help="Pretrained LoRA path. Required if the training is resumed.",
252
+ )
253
+ parser.add_argument(
254
+ "--use_swanlab",
255
+ default=False,
256
+ action="store_true",
257
+ help="Whether to use SwanLab logger.",
258
+ )
259
+ parser.add_argument(
260
+ "--swanlab_mode",
261
+ default=None,
262
+ help="SwanLab mode (cloud or local).",
263
+ )
264
+ return parser
265
+
266
+
267
+ def launch_training_task(model, args):
268
+ # dataset and data loader
269
+ dataset = TextImageDataset(
270
+ args.dataset_path,
271
+ steps_per_epoch=args.steps_per_epoch * args.batch_size,
272
+ height=args.height,
273
+ width=args.width,
274
+ center_crop=args.center_crop,
275
+ random_flip=args.random_flip
276
+ )
277
+ train_loader = torch.utils.data.DataLoader(
278
+ dataset,
279
+ shuffle=True,
280
+ batch_size=args.batch_size,
281
+ num_workers=args.dataloader_num_workers
282
+ )
283
+ # train
284
+ if args.use_swanlab:
285
+ from swanlab.integration.pytorch_lightning import SwanLabLogger
286
+ swanlab_config = {"UPPERFRAMEWORK": "DiffSynth-Studio"}
287
+ swanlab_config.update(vars(args))
288
+ swanlab_logger = SwanLabLogger(
289
+ project="diffsynth_studio",
290
+ name="diffsynth_studio",
291
+ config=swanlab_config,
292
+ mode=args.swanlab_mode,
293
+ logdir=os.path.join(args.output_path, "swanlog"),
294
+ )
295
+ logger = [swanlab_logger]
296
+ else:
297
+ logger = None
298
+ trainer = pl.Trainer(
299
+ max_epochs=args.max_epochs,
300
+ accelerator="gpu",
301
+ devices="auto",
302
+ precision=args.precision,
303
+ strategy=args.training_strategy,
304
+ default_root_dir=args.output_path,
305
+ accumulate_grad_batches=args.accumulate_grad_batches,
306
+ callbacks=[pl.pytorch.callbacks.ModelCheckpoint(save_top_k=-1)],
307
+ logger=logger,
308
+ )
309
+ trainer.fit(model=model, train_dataloaders=train_loader)
310
+
311
+ # Upload models
312
+ if args.modelscope_model_id is not None and args.modelscope_access_token is not None:
313
+ print(f"Uploading models to modelscope. model_id: {args.modelscope_model_id} local_path: {trainer.log_dir}")
314
+ with open(os.path.join(trainer.log_dir, "configuration.json"), "w", encoding="utf-8") as f:
315
+ f.write('{"framework":"Pytorch","task":"text-to-image-synthesis"}\n')
316
+ api = HubApi()
317
+ api.login(args.modelscope_access_token)
318
+ api.push_model(model_id=args.modelscope_model_id, model_dir=trainer.log_dir)
code/diffsynth/trainers/utils.py ADDED
@@ -0,0 +1,1368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import imageio, os, torch, warnings, torchvision, argparse, json, random
2
+ from peft import LoraConfig, inject_adapter_in_model
3
+ from PIL import Image
4
+ import pandas as pd
5
+ from tqdm import tqdm
6
+ from accelerate import Accelerator
7
+
8
+
9
+
10
+ class ImageDataset(torch.utils.data.Dataset):
11
+ def __init__(
12
+ self,
13
+ base_path=None, metadata_path=None,
14
+ max_pixels=1920*1080, height=None, width=None,
15
+ height_division_factor=16, width_division_factor=16,
16
+ data_file_keys=("image",),
17
+ image_file_extension=("jpg", "jpeg", "png", "webp"),
18
+ repeat=1,
19
+ args=None,
20
+ ):
21
+ if args is not None:
22
+ base_path = args.dataset_base_path
23
+ metadata_path = args.dataset_metadata_path
24
+ height = args.height
25
+ width = args.width
26
+ max_pixels = args.max_pixels
27
+ data_file_keys = args.data_file_keys.split(",")
28
+ repeat = args.dataset_repeat
29
+
30
+ self.base_path = base_path
31
+ self.max_pixels = max_pixels
32
+ self.height = height
33
+ self.width = width
34
+ self.height_division_factor = height_division_factor
35
+ self.width_division_factor = width_division_factor
36
+ self.data_file_keys = data_file_keys
37
+ self.image_file_extension = image_file_extension
38
+ self.repeat = repeat
39
+
40
+ if height is not None and width is not None:
41
+ print("Height and width are fixed. Setting `dynamic_resolution` to False.")
42
+ self.dynamic_resolution = False
43
+ elif height is None and width is None:
44
+ print("Height and width are none. Setting `dynamic_resolution` to True.")
45
+ self.dynamic_resolution = True
46
+
47
+ if metadata_path is None:
48
+ print("No metadata. Trying to generate it.")
49
+ metadata = self.generate_metadata(base_path)
50
+ print(f"{len(metadata)} lines in metadata.")
51
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
52
+ elif metadata_path.endswith(".json"):
53
+ with open(metadata_path, "r") as f:
54
+ metadata = json.load(f)
55
+ self.data = metadata
56
+ else:
57
+ metadata = pd.read_csv(metadata_path)
58
+ # Ensure prompt column is string type to avoid float conversion for NaN values
59
+ if 'prompt' in metadata.columns:
60
+ metadata['prompt'] = metadata['prompt'].astype(str)
61
+ # Replace 'nan' string (from NaN) with empty string
62
+ metadata['prompt'] = metadata['prompt'].replace('nan', '')
63
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
64
+
65
+
66
+ def generate_metadata(self, folder):
67
+ image_list, prompt_list = [], []
68
+ file_set = set(os.listdir(folder))
69
+ for file_name in file_set:
70
+ if "." not in file_name:
71
+ continue
72
+ file_ext_name = file_name.split(".")[-1].lower()
73
+ file_base_name = file_name[:-len(file_ext_name)-1]
74
+ if file_ext_name not in self.image_file_extension:
75
+ continue
76
+ prompt_file_name = file_base_name + ".txt"
77
+ if prompt_file_name not in file_set:
78
+ continue
79
+ with open(os.path.join(folder, prompt_file_name), "r", encoding="utf-8") as f:
80
+ prompt = f.read().strip()
81
+ image_list.append(file_name)
82
+ prompt_list.append(prompt)
83
+ metadata = pd.DataFrame()
84
+ metadata["image"] = image_list
85
+ metadata["prompt"] = prompt_list
86
+ return metadata
87
+
88
+
89
+ def crop_and_resize(self, image, target_height, target_width):
90
+ width, height = image.size
91
+ scale = max(target_width / width, target_height / height)
92
+ image = torchvision.transforms.functional.resize(
93
+ image,
94
+ (round(height*scale), round(width*scale)),
95
+ interpolation=torchvision.transforms.InterpolationMode.BILINEAR
96
+ )
97
+ image = torchvision.transforms.functional.center_crop(image, (target_height, target_width))
98
+ return image
99
+
100
+
101
+ def get_height_width(self, image):
102
+ if self.dynamic_resolution:
103
+ width, height = image.size
104
+ if width * height > self.max_pixels:
105
+ scale = (width * height / self.max_pixels) ** 0.5
106
+ height, width = int(height / scale), int(width / scale)
107
+ height = height // self.height_division_factor * self.height_division_factor
108
+ width = width // self.width_division_factor * self.width_division_factor
109
+ else:
110
+ height, width = self.height, self.width
111
+ return height, width
112
+
113
+
114
+ def load_image(self, file_path):
115
+ image = Image.open(file_path).convert("RGB")
116
+ image = self.crop_and_resize(image, *self.get_height_width(image))
117
+ return image
118
+
119
+
120
+ def load_data(self, file_path):
121
+ return self.load_image(file_path)
122
+
123
+
124
+ def __getitem__(self, data_id):
125
+ data = self.data[data_id % len(self.data)].copy()
126
+ for key in self.data_file_keys:
127
+ if key in data:
128
+ path = os.path.join(self.base_path, data[key])
129
+ data[key] = self.load_data(path)
130
+ if data[key] is None:
131
+ warnings.warn(f"cannot load file {data[key]}.")
132
+ return None
133
+ return data
134
+
135
+
136
+ def __len__(self):
137
+ return len(self.data) * self.repeat
138
+
139
+
140
+
141
+ class VideoDataset(torch.utils.data.Dataset):
142
+ def __init__(
143
+ self,
144
+ base_path=None, metadata_path=None,
145
+ num_frames=81,
146
+ time_division_factor=4, time_division_remainder=1,
147
+ max_pixels=1920*1080, height=None, width=None,
148
+ height_division_factor=16, width_division_factor=16,
149
+ data_file_keys=("video",),
150
+ image_file_extension=("jpg", "jpeg", "png", "webp"),
151
+ video_file_extension=("mp4", "avi", "mov", "wmv", "mkv", "flv", "webm"),
152
+ repeat=1,
153
+ args=None,
154
+ action_base_path=None,
155
+ enable_icl=False,
156
+ icl_num_examples=2,
157
+ icl_context_frames=8,
158
+ ):
159
+ if args is not None:
160
+ base_path = args.dataset_base_path
161
+ metadata_path = args.dataset_metadata_path
162
+ height = args.height
163
+ width = args.width
164
+ max_pixels = args.max_pixels
165
+ num_frames = args.num_frames
166
+ data_file_keys = args.data_file_keys.split(",")
167
+ repeat = args.dataset_repeat
168
+ # In-context learning parameters
169
+ if hasattr(args, 'enable_icl'):
170
+ enable_icl = args.enable_icl
171
+ if hasattr(args, 'icl_num_examples'):
172
+ icl_num_examples = args.icl_num_examples
173
+ if hasattr(args, 'icl_context_frames'):
174
+ icl_context_frames = args.icl_context_frames
175
+
176
+ self.base_path = base_path
177
+ self.num_frames = num_frames
178
+ self.time_division_factor = time_division_factor
179
+ self.time_division_remainder = time_division_remainder
180
+ self.max_pixels = max_pixels
181
+ self.height = height
182
+ self.width = width
183
+ self.height_division_factor = height_division_factor
184
+ self.width_division_factor = width_division_factor
185
+ self.data_file_keys = data_file_keys
186
+ self.image_file_extension = image_file_extension
187
+ self.video_file_extension = video_file_extension
188
+ self.repeat = repeat
189
+
190
+ # In-context learning parameters
191
+ self.enable_icl = enable_icl
192
+ self.icl_num_examples = icl_num_examples
193
+ self.icl_context_frames = icl_context_frames
194
+
195
+ if height is not None and width is not None:
196
+ print("Height and width are fixed. Setting `dynamic_resolution` to False.")
197
+ self.dynamic_resolution = False
198
+ elif height is None and width is None:
199
+ print("Height and width are none. Setting `dynamic_resolution` to True.")
200
+ self.dynamic_resolution = True
201
+
202
+ if metadata_path is None:
203
+ print("No metadata. Trying to generate it.")
204
+ metadata = self.generate_metadata(base_path)
205
+ print(f"{len(metadata)} lines in metadata.")
206
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
207
+ elif metadata_path.endswith(".json"):
208
+ with open(metadata_path, "r") as f:
209
+ metadata = json.load(f)
210
+ self.data = metadata
211
+ else:
212
+ metadata = pd.read_csv(metadata_path)
213
+ # Ensure prompt column is string type to avoid float conversion for NaN values
214
+ if 'prompt' in metadata.columns:
215
+ metadata['prompt'] = metadata['prompt'].astype(str)
216
+ # Replace 'nan' string (from NaN) with empty string
217
+ metadata['prompt'] = metadata['prompt'].replace('nan', '')
218
+
219
+ # CRITICAL FIX: Clean prompt - remove video path prefix if present
220
+ # Some CSV prompts start with "video_name.mp4 " prefix, which should be removed
221
+ def clean_prompt(prompt_str):
222
+ if not isinstance(prompt_str, str) or not prompt_str:
223
+ return prompt_str
224
+ # Check if prompt starts with a video path (contains .mp4 or /)
225
+ # Pattern: "VideoName/1234_5678.mp4 " or "VideoName.mp4 "
226
+ import re
227
+ # Match pattern: word/word.mp4 or word.mp4 at the start, followed by space
228
+ pattern = r'^[A-Za-z0-9_]+(/[A-Za-z0-9_]+)?\.mp4\s+'
229
+ cleaned = re.sub(pattern, '', prompt_str)
230
+ # Also handle truncated prompts ending with "..."
231
+ if cleaned.endswith('...'):
232
+ cleaned = cleaned[:-3].rstrip()
233
+ return cleaned.strip()
234
+
235
+ metadata['prompt'] = metadata['prompt'].apply(clean_prompt)
236
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
237
+
238
+ self.action_base_path = action_base_path
239
+
240
+ if self.enable_icl:
241
+ print(f"In-context learning enabled: {icl_num_examples} examples, {icl_context_frames} context frames each")
242
+
243
+
244
+ def generate_metadata(self, folder):
245
+ video_list, prompt_list = [], []
246
+ file_set = set(os.listdir(folder))
247
+ for file_name in file_set:
248
+ if "." not in file_name:
249
+ continue
250
+ file_ext_name = file_name.split(".")[-1].lower()
251
+ file_base_name = file_name[:-len(file_ext_name)-1]
252
+ if file_ext_name not in self.image_file_extension and file_ext_name not in self.video_file_extension:
253
+ continue
254
+ prompt_file_name = file_base_name + ".txt"
255
+ if prompt_file_name not in file_set:
256
+ continue
257
+ with open(os.path.join(folder, prompt_file_name), "r", encoding="utf-8") as f:
258
+ prompt = f.read().strip()
259
+ video_list.append(file_name)
260
+ prompt_list.append(prompt)
261
+ metadata = pd.DataFrame()
262
+ metadata["video"] = video_list
263
+ metadata["prompt"] = prompt_list
264
+ return metadata
265
+
266
+
267
+ def crop_and_resize(self, image, target_height, target_width):
268
+ width, height = image.size
269
+ scale = max(target_width / width, target_height / height)
270
+ image = torchvision.transforms.functional.resize(
271
+ image,
272
+ (round(height*scale), round(width*scale)),
273
+ interpolation=torchvision.transforms.InterpolationMode.BILINEAR
274
+ )
275
+ image = torchvision.transforms.functional.center_crop(image, (target_height, target_width))
276
+ return image
277
+
278
+
279
+ def get_height_width(self, image):
280
+ if self.dynamic_resolution:
281
+ width, height = image.size
282
+ if width * height > self.max_pixels:
283
+ scale = (width * height / self.max_pixels) ** 0.5
284
+ height, width = int(height / scale), int(width / scale)
285
+ height = height // self.height_division_factor * self.height_division_factor
286
+ width = width // self.width_division_factor * self.width_division_factor
287
+ else:
288
+ height, width = self.height, self.width
289
+ return height, width
290
+
291
+
292
+ def get_num_frames(self, reader):
293
+ num_frames = self.num_frames
294
+ if int(reader.count_frames()) < num_frames:
295
+ num_frames = int(reader.count_frames())
296
+ while num_frames > 1 and num_frames % self.time_division_factor != self.time_division_remainder:
297
+ num_frames -= 1
298
+ return num_frames
299
+
300
+
301
+ def load_video(self, file_path):
302
+ reader = imageio.get_reader(file_path)
303
+ num_frames = self.get_num_frames(reader)
304
+ frames = []
305
+ for frame_id in range(num_frames):
306
+ frame = reader.get_data(frame_id)
307
+ frame = Image.fromarray(frame)
308
+ frame = self.crop_and_resize(frame, *self.get_height_width(frame))
309
+ frames.append(frame)
310
+ reader.close()
311
+ return frames
312
+
313
+
314
+ def load_image(self, file_path):
315
+ image = Image.open(file_path).convert("RGB")
316
+ image = self.crop_and_resize(image, *self.get_height_width(image))
317
+ frames = [image]
318
+ return frames
319
+
320
+
321
+ def is_image(self, file_path):
322
+ file_ext_name = file_path.split(".")[-1]
323
+ return file_ext_name.lower() in self.image_file_extension
324
+
325
+
326
+ def is_video(self, file_path):
327
+ file_ext_name = file_path.split(".")[-1]
328
+ return file_ext_name.lower() in self.video_file_extension
329
+
330
+
331
+ def load_data(self, file_path):
332
+ # Handle multiple frame paths separated by '|' (for frame sequences)
333
+ if '|' in str(file_path):
334
+ # Split the path by '|' to get individual frame paths
335
+ frame_paths = str(file_path).split('|')
336
+ frames = []
337
+
338
+ # Get base_path (dataset root)
339
+ if not hasattr(self, 'base_path') or not self.base_path:
340
+ warnings.warn(f"Cannot determine base directory for frame sequence: {file_path}")
341
+ return None
342
+
343
+ base_dir = self.base_path # This is the dataset root
344
+
345
+ # Check the first path to determine the format
346
+ first_frame = frame_paths[0].strip() if frame_paths else ""
347
+
348
+ # If first frame is already an absolute path (from __getitem__ joining),
349
+ # extract the base directory from it
350
+ if os.path.isabs(first_frame):
351
+ # Extract base directory from first frame path
352
+ # First frame format: /path/to/dataset/frames/video_name/frame.png
353
+ # We need to get /path/to/dataset
354
+ parts = first_frame.split(os.sep)
355
+ # Find 'frames' in the path and get everything before it
356
+ if 'frames' in parts:
357
+ frames_idx = parts.index('frames')
358
+ base_dir = os.sep.join(parts[:frames_idx])
359
+ else:
360
+ # Fallback: use self.base_path
361
+ base_dir = self.base_path
362
+
363
+ for frame_path in frame_paths:
364
+ frame_path = frame_path.strip()
365
+ if not frame_path:
366
+ continue
367
+
368
+ # Construct full path
369
+ if os.path.isabs(frame_path):
370
+ # Already absolute path (from __getitem__)
371
+ full_frame_path = frame_path
372
+ else:
373
+ # Relative path - need to construct full path
374
+ # Remove 'frames/' prefix if present (we'll add it consistently)
375
+ if frame_path.startswith('frames/'):
376
+ frame_path = frame_path[7:] # Remove 'frames/' prefix
377
+
378
+ # Always join with base_dir + 'frames/' since base_dir is dataset root
379
+ full_frame_path = os.path.join(base_dir, 'frames', frame_path)
380
+
381
+ # Load individual frame
382
+ if os.path.exists(full_frame_path):
383
+ if self.is_image(full_frame_path):
384
+ frame_data = self.load_image(full_frame_path)
385
+ if frame_data:
386
+ frames.extend(frame_data)
387
+ else:
388
+ warnings.warn(f"Frame is not an image: {full_frame_path}")
389
+ else:
390
+ warnings.warn(f"Frame not found: {full_frame_path}")
391
+
392
+ if frames:
393
+ return frames
394
+ else:
395
+ warnings.warn(f"No frames loaded from sequence: {file_path}")
396
+ return None
397
+
398
+ # Handle single file (image or video)
399
+ if self.is_image(file_path):
400
+ return self.load_image(file_path)
401
+ elif self.is_video(file_path):
402
+ return self.load_video(file_path)
403
+ else:
404
+ return None
405
+
406
+
407
+ def __getitem__(self, data_id):
408
+ data = self.data[data_id % len(self.data)].copy()
409
+ for key in self.data_file_keys:
410
+ if key in ["video_name", "start_frame", "end_frame"]:
411
+ if "actions" in data:
412
+ continue
413
+ try:
414
+ video_name = data.get("video_name")
415
+ if video_name is None:
416
+ warnings.warn(f"video_name is missing in metadata for data_id {data_id}. Skipping action loading.")
417
+ continue
418
+
419
+ if video_name.endswith(".mp4"):
420
+ video_name = ".".join(video_name.split(".")[:-1])
421
+ if "_" in video_name:
422
+ video_name = "_".join(video_name.split("_")[:4])
423
+
424
+ import json
425
+ json_path = os.path.join(self.action_base_path, video_name + ".json")
426
+
427
+ # Check if action file exists
428
+ if not os.path.exists(json_path):
429
+ warnings.warn(f"Action file does not exist: {json_path}. Skipping action loading for data_id {data_id}.")
430
+ continue
431
+
432
+ start_frame = data.get("start_frame")
433
+ end_frame = data.get("end_frame")
434
+ if start_frame is None or end_frame is None:
435
+ warnings.warn(f"start_frame or end_frame is missing in metadata for data_id {data_id}. Skipping action loading.")
436
+ continue
437
+
438
+ json_data = json.load(open(json_path, "r"))['actions']
439
+ actions = []
440
+ current_yaw = 0.0
441
+ for frame_id in range(start_frame+1, end_frame+1):
442
+ frame_str = str(frame_id)
443
+ if frame_str not in json_data:
444
+ warnings.warn(f"Frame {frame_id} not found in action file {json_path}. Skipping this frame.")
445
+ continue
446
+
447
+ action = json_data[frame_str]
448
+ new_action = [0.0] * (2 + 2 + 3 + 1 + 2)
449
+ if action['ws'] == 1:
450
+ new_action[0] = 1
451
+ elif action['ws'] == 2:
452
+ new_action[1] = 1
453
+
454
+ if action['ad'] == 1:
455
+ new_action[2] = 1
456
+ elif action['ad'] == 2:
457
+ new_action[3] = 1
458
+
459
+ if action['scs'] == 1 and action.get("jump_invalid", 0) == 0:
460
+ new_action[4] = 1
461
+ elif action['scs'] == 2:
462
+ new_action[5] = 1
463
+ elif action['scs'] == 3:
464
+ new_action[6] = 1
465
+
466
+ if action.get('collision', 0) == 1:
467
+ new_action[7] = 1
468
+ new_action[0] = 0
469
+ new_action[1] = 0
470
+ new_action[2] = 0
471
+ new_action[3] = 0
472
+
473
+ pre_pitch = action.get('pre_pitch', 0.0)
474
+ current_pitch = pre_pitch + action.get('pitch_delta', 0.0) * 15.0
475
+ current_yaw += action.get('yaw_delta', 0.0) * 15.0
476
+ new_action[8] = current_pitch
477
+ new_action[9] = current_yaw
478
+
479
+ actions.append(new_action)
480
+ data["actions"] = actions
481
+ except Exception as e:
482
+ warnings.warn(f"Exception while loading actions for data_id {data_id}: {e}. Continuing without actions.")
483
+ # Don't return None, just continue without actions
484
+ continue
485
+ elif key == "video":
486
+ # Check if data[key] exists and is not None
487
+ if key not in data or data[key] is None:
488
+ warnings.warn(f"Video key '{key}' is missing or None in metadata for data_id {data_id}. Skipping this sample.")
489
+ return None
490
+
491
+ # Handle frame sequences (paths with '|' separator)
492
+ video_path_str = str(data[key])
493
+ if '|' in video_path_str:
494
+ # For frame sequences, pass the full path string to load_data
495
+ # load_data will handle splitting and loading individual frames
496
+ path = os.path.join(self.base_path, video_path_str)
497
+ # Don't check path existence here for frame sequences
498
+ # load_data will handle individual frame loading
499
+ else:
500
+ path = os.path.join(self.base_path, data[key])
501
+ # Check if path exists (only for single files)
502
+ if not os.path.exists(path):
503
+ warnings.warn(f"Video file does not exist: {path}. Skipping this sample.")
504
+ return None
505
+ try:
506
+ data[key] = self.load_data(path)
507
+ if data[key] is None:
508
+ warnings.warn(f"Failed to load video file: {path}. load_data returned None.")
509
+ return None
510
+ except Exception as e:
511
+ warnings.warn(f"Exception while loading video file {path}: {e}. Skipping this sample.")
512
+ return None
513
+
514
+ # In-context learning: sample context examples from dataset
515
+ if self.enable_icl and len(self.data) > 1:
516
+ context_frames_list = []
517
+ context_actions_list = []
518
+
519
+ # Sample random examples from dataset (excluding current one)
520
+ current_idx = data_id % len(self.data)
521
+ candidate_indices = [i for i in range(len(self.data)) if i != current_idx]
522
+ if len(candidate_indices) > 0:
523
+ num_samples = min(self.icl_num_examples, len(candidate_indices))
524
+ sampled_indices = random.sample(candidate_indices, num_samples)
525
+
526
+ for sample_idx in sampled_indices:
527
+ sample_data = self.data[sample_idx].copy()
528
+ # Load video for context
529
+ if "video" in self.data_file_keys and "video" in sample_data:
530
+ video_path = os.path.join(self.base_path, sample_data["video"])
531
+ sample_video = self.load_data(video_path)
532
+ if sample_video is not None and len(sample_video) >= self.icl_context_frames:
533
+ # Sample context_frames from the video
534
+ start_idx = random.randint(0, max(0, len(sample_video) - self.icl_context_frames))
535
+ context_frames = sample_video[start_idx:start_idx + self.icl_context_frames]
536
+ context_frames_list.extend(context_frames)
537
+
538
+ # Load corresponding actions if available
539
+ if self.action_base_path is not None and "video_name" in sample_data:
540
+ try:
541
+ sample_video_name = sample_data["video_name"]
542
+ if sample_video_name.endswith(".mp4"):
543
+ sample_video_name = ".".join(sample_video_name.split(".")[:-1])
544
+ if "_" in sample_video_name:
545
+ sample_video_name = "_".join(sample_video_name.split("_")[:4])
546
+ sample_json_path = os.path.join(self.action_base_path, sample_video_name + ".json")
547
+ if os.path.exists(sample_json_path):
548
+ sample_json_data = json.load(open(sample_json_path, "r"))['actions']
549
+ sample_start_frame = sample_data.get("start_frame", 0)
550
+ sample_end_frame = sample_data.get("end_frame", len(sample_video))
551
+
552
+ # Get actions for the context frames
553
+ context_actions = []
554
+ context_yaw = 0.0
555
+ for frame_idx in range(sample_start_frame + start_idx + 1,
556
+ min(sample_start_frame + start_idx + self.icl_context_frames + 1, sample_end_frame + 1)):
557
+ if str(frame_idx) in sample_json_data:
558
+ action = sample_json_data[str(frame_idx)]
559
+ new_action = [0.0] * (2 + 2 + 3 + 1 + 2)
560
+ if action['ws'] == 1:
561
+ new_action[0] = 1
562
+ elif action['ws'] == 2:
563
+ new_action[1] = 1
564
+ if action['ad'] == 1:
565
+ new_action[2] = 1
566
+ elif action['ad'] == 2:
567
+ new_action[3] = 1
568
+ if action['scs'] == 1 and action.get("jump_invalid", 0) == 0:
569
+ new_action[4] = 1
570
+ elif action['scs'] == 2:
571
+ new_action[5] = 1
572
+ elif action['scs'] == 3:
573
+ new_action[6] = 1
574
+ if action.get('collision', 0) == 1:
575
+ new_action[7] = 1
576
+ new_action[0] = 0
577
+ new_action[1] = 0
578
+ new_action[2] = 0
579
+ new_action[3] = 0
580
+ pre_pitch = action.get('pre_pitch', 0.0)
581
+ current_pitch = pre_pitch + action.get('pitch_delta', 0.0) * 15.0
582
+ context_yaw += action.get('yaw_delta', 0.0) * 15.0
583
+ new_action[8] = current_pitch
584
+ new_action[9] = context_yaw
585
+ context_actions.append(new_action)
586
+ context_actions_list.extend(context_actions[:len(context_frames)])
587
+ except Exception as e:
588
+ # If loading actions fails, just skip
589
+ pass
590
+
591
+ if context_frames_list:
592
+ data["context_frames"] = context_frames_list
593
+ if context_actions_list and len(context_actions_list) == len(context_frames_list):
594
+ data["context_actions"] = context_actions_list
595
+
596
+ return data
597
+
598
+
599
+ def __len__(self):
600
+ return len(self.data) * self.repeat
601
+
602
+ @staticmethod
603
+ def get_one_hot(action, range=2):
604
+ one_hot = [0] * (range + 1)
605
+ one_hot[action] = 1
606
+ return one_hot
607
+
608
+
609
+
610
+ import numpy as np
611
+
612
+
613
+ class CamVideoDataset(torch.utils.data.Dataset):
614
+ """Dataset for Context-as-Memory camera pose conditioned training (ported from VWM).
615
+
616
+ Loads 81 PNG frames from UE scenes with random temporal cropping and extracts
617
+ corresponding camera poses as 12-dim relative RT vectors subsampled to match
618
+ the 21 latent frames.
619
+ """
620
+ def __init__(
621
+ self,
622
+ base_path=None, metadata_path=None,
623
+ num_frames=81,
624
+ height=None, width=None,
625
+ max_pixels=1920*1080,
626
+ height_division_factor=16, width_division_factor=16,
627
+ repeat=1,
628
+ args=None,
629
+ cam_position_scale=None,
630
+ ):
631
+ if args is not None:
632
+ base_path = args.dataset_base_path
633
+ metadata_path = getattr(args, "dataset_metadata_path", metadata_path)
634
+ height = args.height
635
+ width = args.width
636
+ max_pixels = args.max_pixels
637
+ num_frames = args.num_frames
638
+ repeat = args.dataset_repeat
639
+ cam_position_scale = getattr(args, "cam_position_scale", 0.01)
640
+ self.use_condition_context_frames = getattr(args, "use_condition_context_frames", False)
641
+ self.condition_first_frame = getattr(args, "condition_first_frame", False)
642
+ self.condition_history_keyframes = getattr(args, "condition_history_keyframes", False)
643
+ self.condition_use_camera_pose = getattr(args, "condition_use_camera_pose", True)
644
+ self.num_condition_frames = getattr(args, "num_condition_frames", 1)
645
+ self.condition_frame_mode = getattr(args, "condition_frame_mode", "first_frame_only")
646
+ self.overlap_labels_root = getattr(args, "overlap_labels_root", None)
647
+ self.condition_t2v_ratio = getattr(args, "condition_t2v_ratio", 0.10)
648
+ self.condition_i2v_ratio = getattr(args, "condition_i2v_ratio", 0.10)
649
+ self.use_geometry_spatial_memory = getattr(args, "use_geometry_spatial_memory", False)
650
+ self.geometry_memory_column = getattr(args, "geometry_memory_column", "geometry_memory")
651
+ self.geometry_memory_root = getattr(args, "geometry_memory_root", None)
652
+ else:
653
+ self.use_condition_context_frames = False
654
+ self.condition_first_frame = False
655
+ self.condition_history_keyframes = False
656
+ self.condition_use_camera_pose = True
657
+ self.num_condition_frames = 1
658
+ self.condition_frame_mode = "first_frame_only"
659
+ self.overlap_labels_root = None
660
+ self.condition_t2v_ratio = 0.10
661
+ self.condition_i2v_ratio = 0.10
662
+ self.use_geometry_spatial_memory = False
663
+ self.geometry_memory_column = "geometry_memory"
664
+ self.geometry_memory_root = None
665
+
666
+ if cam_position_scale is None:
667
+ cam_position_scale = 0.01
668
+ self.cam_position_scale = float(cam_position_scale)
669
+
670
+ self.base_path = base_path
671
+ self.frames_dir = os.path.join(base_path, "frames")
672
+ self.jsons_dir = os.path.join(base_path, "jsons")
673
+ self.num_frames = num_frames
674
+ self.max_pixels = max_pixels
675
+ self.height = height
676
+ self.width = width
677
+ self.height_division_factor = height_division_factor
678
+ self.width_division_factor = width_division_factor
679
+ self.repeat = repeat
680
+
681
+ if height is not None and width is not None:
682
+ self.dynamic_resolution = False
683
+ else:
684
+ self.dynamic_resolution = True
685
+
686
+ if metadata_path is None:
687
+ print("No metadata. Trying to generate it.")
688
+ metadata = self.generate_metadata(base_path)
689
+ print(f"{len(metadata)} lines in metadata.")
690
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
691
+ elif metadata_path.endswith(".json"):
692
+ with open(metadata_path, "r") as f:
693
+ metadata = json.load(f)
694
+ self.data = metadata
695
+ else:
696
+ metadata = pd.read_csv(metadata_path)
697
+ # Ensure prompt column is string type to avoid float conversion for NaN values
698
+ if 'prompt' in metadata.columns:
699
+ metadata['prompt'] = metadata['prompt'].astype(str)
700
+ # Replace 'nan' string (from NaN) with empty string
701
+ metadata['prompt'] = metadata['prompt'].replace('nan', '')
702
+
703
+ # CRITICAL FIX: Clean prompt - remove video path prefix if present
704
+ # Some CSV prompts start with "video_name.mp4 " prefix, which should be removed
705
+ def clean_prompt(prompt_str):
706
+ if not isinstance(prompt_str, str) or not prompt_str:
707
+ return prompt_str
708
+ # Check if prompt starts with a video path (contains .mp4 or /)
709
+ # Pattern: "VideoName/1234_5678.mp4 " or "VideoName.mp4 "
710
+ import re
711
+ # Match pattern: word/word.mp4 or word.mp4 at the start, followed by space
712
+ pattern = r'^[A-Za-z0-9_]+(/[A-Za-z0-9_]+)?\.mp4\s+'
713
+ cleaned = re.sub(pattern, '', prompt_str)
714
+ # Also handle truncated prompts ending with "..."
715
+ if cleaned.endswith('...'):
716
+ cleaned = cleaned[:-3].rstrip()
717
+ return cleaned.strip()
718
+
719
+ metadata['prompt'] = metadata['prompt'].apply(clean_prompt)
720
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
721
+
722
+ captions_path = os.path.join(base_path, "captions.txt")
723
+ self.scene_captions = {}
724
+ with open(captions_path, "r") as f:
725
+ for line in f:
726
+ parts = line.strip().split("\t", 1)
727
+ if len(parts) < 2:
728
+ continue
729
+ clip_path, caption = parts
730
+ scene_name = "/".join(clip_path.split("/")[:-1])
731
+ fname = clip_path.split("/")[-1].replace(".mp4", "")
732
+ clip_start = int(fname.split("_")[0])
733
+ if scene_name not in self.scene_captions:
734
+ self.scene_captions[scene_name] = []
735
+ self.scene_captions[scene_name].append((clip_start, caption))
736
+
737
+ for scene_name in self.scene_captions:
738
+ self.scene_captions[scene_name].sort(key=lambda x: x[0])
739
+
740
+ self.scene_names = sorted(self.scene_captions.keys())
741
+ self.metadata_rows = []
742
+ if metadata_path and os.path.isfile(metadata_path):
743
+ metadata = pd.read_csv(metadata_path)
744
+ if "prompt" in metadata.columns:
745
+ metadata["prompt"] = metadata["prompt"].astype(str)
746
+ self.metadata_rows = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
747
+ self.pose_cache = {}
748
+ self.overlap_cache = {}
749
+ self.invalid_scenes = set()
750
+ self.invalid_metadata_indices = set()
751
+ self.overlap_labels_root = self._resolve_overlap_labels_root(base_path, self.overlap_labels_root)
752
+ self._validate_condition_config()
753
+
754
+ total_items = len(self.data)
755
+ total_scenes = len(self.scene_names)
756
+ total_captions = sum(len(v) for v in self.scene_captions.values())
757
+ metadata_msg = f", metadata_rows={len(self.metadata_rows)}" if self.metadata_rows else ""
758
+ effective_len = (len(self.metadata_rows) if self.metadata_rows else total_scenes) * repeat
759
+ print(f"CamVideoDataset: {total_scenes} scenes, {total_captions} captions{metadata_msg}, "
760
+ f"repeat={repeat}, cam_position_scale={self.cam_position_scale}, "
761
+ f"effective length={total_items}")
762
+
763
+ def _resolve_overlap_labels_root(self, base_path, overlap_labels_root):
764
+ candidate_roots = []
765
+ if overlap_labels_root is not None:
766
+ candidate_roots.append(overlap_labels_root)
767
+ if base_path is not None:
768
+ candidate_roots.append(os.path.join(base_path, "overlap_labels"))
769
+ for root in candidate_roots:
770
+ if root is not None and os.path.isdir(root):
771
+ return root
772
+ return overlap_labels_root
773
+
774
+ def _validate_condition_config(self):
775
+ if self.condition_t2v_ratio < 0 or self.condition_i2v_ratio < 0:
776
+ raise ValueError("Condition sampling ratios must be non-negative.")
777
+ if self.condition_t2v_ratio + self.condition_i2v_ratio >= 1.0:
778
+ raise ValueError("condition_t2v_ratio + condition_i2v_ratio must be < 1.0.")
779
+ needs_overlap = (
780
+ self.use_condition_context_frames
781
+ and self.condition_frame_mode == "first_plus_overlap"
782
+ and self.condition_history_keyframes
783
+ and self.num_condition_frames > 1
784
+ )
785
+ if needs_overlap and (self.overlap_labels_root is None or not os.path.isdir(self.overlap_labels_root)):
786
+ raise FileNotFoundError(
787
+ "K-frame condition mode requires overlap_labels_root. "
788
+ "Pass --overlap_labels_root or keep overlap_labels under dataset_base_path/overlap_labels."
789
+ )
790
+
791
+ def _load_scene_poses(self, scene_name):
792
+ if scene_name not in self.pose_cache:
793
+ json_path = os.path.join(self.jsons_dir, scene_name + ".json")
794
+ try:
795
+ with open(json_path, "r") as f:
796
+ data = json.load(f)
797
+ except (FileNotFoundError, json.JSONDecodeError) as e:
798
+ raise ValueError(f"Pose JSON for scene '{scene_name}' is missing or corrupt: {e}")
799
+ if not isinstance(data, dict) or "CineCameraActor" not in data:
800
+ raise ValueError(
801
+ f"Pose JSON for scene '{scene_name}' lacks 'CineCameraActor' key "
802
+ f"(found keys: {list(data.keys()) if isinstance(data, dict) else type(data).__name__})."
803
+ )
804
+ cine = data["CineCameraActor"]
805
+ if not isinstance(cine, dict) or len(cine) == 0:
806
+ raise ValueError(f"Pose JSON for scene '{scene_name}' has empty 'CineCameraActor' entries.")
807
+ self.pose_cache[scene_name] = cine
808
+ return self.pose_cache[scene_name]
809
+
810
+ def _find_nearest_caption(self, scene_name, start_frame):
811
+ captions = self.scene_captions[scene_name]
812
+ best_idx = 0
813
+ best_dist = abs(captions[0][0] - start_frame)
814
+ for i, (clip_start, _) in enumerate(captions):
815
+ dist = abs(clip_start - start_frame)
816
+ if dist < best_dist:
817
+ best_dist = dist
818
+ best_idx = i
819
+ return captions[best_idx][1]
820
+
821
+ @staticmethod
822
+ def _compute_rt(position, rotation):
823
+ x, y, z = position
824
+ yaw_rad = np.radians(rotation[2])
825
+ cos_y, sin_y = np.cos(yaw_rad), np.sin(yaw_rad)
826
+ R = np.array([[cos_y, -sin_y, 0], [sin_y, cos_y, 0], [0, 0, 1]])
827
+ return [x, y, z] + R.flatten().tolist()
828
+
829
+ @staticmethod
830
+ def _to_relative_rt(rt_list, ref_rt):
831
+ R_ref = np.array(ref_rt[3:]).reshape(3, 3)
832
+ T_ref = np.array(ref_rt[:3]).reshape(3, 1)
833
+ R_ref_inv = R_ref.T
834
+ T_ref_inv = -R_ref_inv @ T_ref
835
+ result = []
836
+ for rt in rt_list:
837
+ R_i = np.array(rt[3:]).reshape(3, 3)
838
+ T_i = np.array(rt[:3]).reshape(3, 1)
839
+ R_new = R_ref_inv @ R_i
840
+ T_new = R_ref_inv @ T_i + T_ref_inv
841
+ result.append(T_new.flatten().tolist() + R_new.flatten().tolist())
842
+ return result
843
+
844
+ def crop_and_resize(self, image, target_height, target_width):
845
+ width, height = image.size
846
+ scale = max(target_width / width, target_height / height)
847
+ image = torchvision.transforms.functional.resize(
848
+ image,
849
+ (round(height * scale), round(width * scale)),
850
+ interpolation=torchvision.transforms.InterpolationMode.BILINEAR
851
+ )
852
+ image = torchvision.transforms.functional.center_crop(image, (target_height, target_width))
853
+ return image
854
+
855
+ def get_height_width(self, image):
856
+ if self.dynamic_resolution:
857
+ width, height = image.size
858
+ if width * height > self.max_pixels:
859
+ scale = (width * height / self.max_pixels) ** 0.5
860
+ height, width = int(height / scale), int(width / scale)
861
+ height = height // self.height_division_factor * self.height_division_factor
862
+ width = width // self.width_division_factor * self.width_division_factor
863
+ else:
864
+ height, width = self.height, self.width
865
+ return height, width
866
+
867
+ def _load_resized_frame(self, scene_name, frame_index, target_height, target_width):
868
+ frame_path = os.path.join(self.frames_dir, scene_name, f"{frame_index:04d}.png")
869
+ img = Image.open(frame_path).convert("RGB")
870
+ return self.crop_and_resize(img, target_height, target_width)
871
+
872
+ @staticmethod
873
+ def _parse_frame_token(token):
874
+ token = str(token).strip()
875
+ if not token:
876
+ return None, None
877
+ parts = token.split("/")
878
+ if parts and parts[0] == "frames":
879
+ parts = parts[1:]
880
+ if len(parts) < 2:
881
+ return None, None
882
+ scene_name = "/".join(parts[:-1])
883
+ stem = os.path.splitext(parts[-1])[0]
884
+ try:
885
+ frame_index = int(stem)
886
+ except ValueError:
887
+ return None, None
888
+ return scene_name, frame_index
889
+
890
+ def _metadata_scene_and_indices(self, row):
891
+ video_field = str(row.get("video", "") or "")
892
+ tokens = [t for t in video_field.split("|") if t]
893
+ parsed = [self._parse_frame_token(t) for t in tokens]
894
+ parsed = [(s, i) for s, i in parsed if s is not None and i is not None]
895
+ if parsed:
896
+ scene_name = str(row.get("video_name", "") or parsed[0][0])
897
+ frame_indices = [i for _, i in parsed[: self.num_frames]]
898
+ else:
899
+ scene_name = str(row.get("video_name", "") or "").strip()
900
+ if not scene_name:
901
+ raise ValueError("metadata row lacks video_name and parseable video paths")
902
+ start_frame = int(row.get("start_frame", 0) or 0)
903
+ frame_indices = list(range(start_frame, start_frame + self.num_frames))
904
+ if len(frame_indices) < self.num_frames:
905
+ raise ValueError(f"metadata row has {len(frame_indices)} frames, expected {self.num_frames}")
906
+ return scene_name, frame_indices[: self.num_frames]
907
+
908
+ def _resolve_geometry_path(self, token):
909
+ token = str(token).strip()
910
+ if not token:
911
+ return None
912
+ if os.path.isabs(token):
913
+ return token
914
+ root = self.geometry_memory_root or self.base_path
915
+ return os.path.join(root, token)
916
+
917
+ def _load_geometry_memory_frames(self, row):
918
+ value = row.get(self.geometry_memory_column, None)
919
+ if value is None or str(value).strip() == "" or str(value).lower() == "nan":
920
+ if self.use_geometry_spatial_memory:
921
+ raise ValueError(
922
+ f"metadata row lacks required geometry column '{self.geometry_memory_column}'"
923
+ )
924
+ return []
925
+ tokens = [token for token in str(value).split("|") if token.strip()]
926
+ if not tokens:
927
+ raise ValueError(f"empty geometry memory field '{self.geometry_memory_column}'")
928
+
929
+ if len(tokens) == 1:
930
+ path = self._resolve_geometry_path(tokens[0])
931
+ if path is None:
932
+ return []
933
+ if os.path.isdir(path):
934
+ names = sorted(
935
+ name
936
+ for name in os.listdir(path)
937
+ if os.path.splitext(name)[1].lower() in (".png", ".jpg", ".jpeg", ".webp")
938
+ )
939
+ paths = [os.path.join(path, name) for name in names]
940
+ return [Image.open(frame_path).convert("RGB") for frame_path in paths]
941
+ if os.path.splitext(path)[1].lower() in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
942
+ reader = imageio.get_reader(path)
943
+ try:
944
+ frames = [Image.fromarray(frame).convert("RGB") for frame in reader]
945
+ finally:
946
+ reader.close()
947
+ if not frames:
948
+ raise ValueError(f"geometry memory video has no frames: {path}")
949
+ return frames
950
+
951
+ frames = []
952
+ for token in tokens:
953
+ path = self._resolve_geometry_path(token)
954
+ if path is None or not os.path.isfile(path):
955
+ raise FileNotFoundError(f"geometry memory frame not found: {path}")
956
+ frames.append(Image.open(path).convert("RGB"))
957
+ return frames
958
+
959
+ def _load_overlap_frames(self, scene_name, frame_index):
960
+ if self.overlap_labels_root is None:
961
+ return []
962
+ cache_key = (scene_name, int(frame_index))
963
+ if cache_key not in self.overlap_cache:
964
+ overlap_path = os.path.join(self.overlap_labels_root, scene_name, f"{int(frame_index)}.json")
965
+ if not os.path.exists(overlap_path):
966
+ self.overlap_cache[cache_key] = []
967
+ else:
968
+ with open(overlap_path, "r") as f:
969
+ overlap_data = json.load(f)
970
+ overlaps = overlap_data.get("overlapping_frames", [])
971
+ self.overlap_cache[cache_key] = [int(idx) for idx in overlaps]
972
+ return self.overlap_cache[cache_key]
973
+
974
+ def _compute_scene_rt(self, scene_name, frame_index):
975
+ frame_data = self._load_scene_poses(scene_name)[str(int(frame_index))]
976
+ raw_pos = frame_data["position"]
977
+ pos = [float(p) * self.cam_position_scale for p in raw_pos]
978
+ return self._compute_rt(pos, frame_data["rotation"])
979
+
980
+ def _sample_condition_mode(self):
981
+ if not self.use_condition_context_frames:
982
+ return "disabled"
983
+ if (
984
+ self.condition_frame_mode != "first_plus_overlap"
985
+ or not self.condition_history_keyframes
986
+ or self.num_condition_frames <= 1
987
+ ):
988
+ return "first_frame_only"
989
+ sample = random.random()
990
+ if sample < self.condition_t2v_ratio:
991
+ return "text_only"
992
+ if sample < self.condition_t2v_ratio + self.condition_i2v_ratio:
993
+ return "first_frame_only"
994
+ return "first_plus_overlap"
995
+
996
+ def _sample_overlap_conditions(self, scene_name, start_frame, ref_rt, target_height, target_width, num_extra_conditions):
997
+ if num_extra_conditions <= 0:
998
+ return [], [], []
999
+ window_indices = set(range(start_frame, start_frame + self.num_frames))
1000
+ target_candidates = list(range(start_frame + 1, start_frame + self.num_frames))
1001
+ sampled_target_frames = random.sample(target_candidates, k=min(num_extra_conditions, len(target_candidates)))
1002
+ overlap_frames = []
1003
+ overlap_indices = []
1004
+ overlap_actions = []
1005
+ used_condition_indices = set()
1006
+ for target_frame_idx in sampled_target_frames:
1007
+ candidate_indices = [
1008
+ idx for idx in self._load_overlap_frames(scene_name, target_frame_idx)
1009
+ if idx not in window_indices and idx != target_frame_idx and idx not in used_condition_indices
1010
+ ]
1011
+ if len(candidate_indices) == 0:
1012
+ return None
1013
+ chosen_idx = random.choice(candidate_indices)
1014
+ used_condition_indices.add(chosen_idx)
1015
+ overlap_indices.append(chosen_idx)
1016
+ overlap_frames.append(self._load_resized_frame(scene_name, chosen_idx, target_height, target_width))
1017
+ if self.condition_use_camera_pose:
1018
+ overlap_rt = self._compute_scene_rt(scene_name, chosen_idx)
1019
+ overlap_actions.append(self._to_relative_rt([overlap_rt], ref_rt)[0])
1020
+ if len(overlap_frames) != num_extra_conditions:
1021
+ return None
1022
+ return overlap_frames, overlap_indices, overlap_actions
1023
+
1024
+ def _try_get_sample(self, data_item):
1025
+ scene_name = data_item["video_name"]
1026
+ cam_data = self._load_scene_poses(scene_name)
1027
+ max_start = len(cam_data) - self.num_frames
1028
+ if max_start < 0:
1029
+ raise ValueError(f"Scene {scene_name} has fewer than {self.num_frames} frames.")
1030
+ start_frame = data_item["start_frame"]
1031
+ end_frame = start_frame + self.num_frames - 1
1032
+ assert end_frame == data_item["end_frame"]
1033
+
1034
+ frames = []
1035
+ for i in range(start_frame, end_frame + 1):
1036
+ frame_path = os.path.join(self.frames_dir, scene_name, f"{i:04d}.png")
1037
+ img = Image.open(frame_path).convert("RGB")
1038
+ img = self.crop_and_resize(img, *self.get_height_width(img))
1039
+ frames.append(img)
1040
+
1041
+ # prompt = self._find_nearest_caption(scene_name, start_frame)
1042
+ prompt = data_item["prompt"]
1043
+
1044
+ rt_list_abs = []
1045
+ for i in range(start_frame, end_frame + 1):
1046
+ key = str(i)
1047
+ if key not in cam_data:
1048
+ raise ValueError(f"Scene {scene_name} missing pose for frame {i}.")
1049
+ frame_data = cam_data[key]
1050
+ raw_pos = frame_data["position"]
1051
+ pos = [float(p) * self.cam_position_scale for p in raw_pos]
1052
+ rt = self._compute_rt(pos, frame_data["rotation"])
1053
+ rt_list_abs.append(rt)
1054
+
1055
+ rt_list = self._to_relative_rt(rt_list_abs, rt_list_abs[0])
1056
+ pose_indices = list(range(0, self.num_frames, 4))
1057
+ actions = [rt_list[i] for i in pose_indices]
1058
+
1059
+ return {
1060
+ "video": frames,
1061
+ "prompt": prompt,
1062
+ "actions": actions,
1063
+ "video_name": scene_name,
1064
+ "start_frame": start_frame,
1065
+ "end_frame": end_frame,
1066
+ **self._build_condition_context_payload(
1067
+ frames=frames,
1068
+ scene_name=scene_name,
1069
+ start_frame=start_frame,
1070
+ ref_rt=rt_list_abs[0],
1071
+ actions=actions,
1072
+ ),
1073
+ }
1074
+
1075
+ def _try_get_metadata_sample(self, row):
1076
+ scene_name, frame_indices = self._metadata_scene_and_indices(row)
1077
+ start_frame = int(frame_indices[0])
1078
+ end_frame = int(frame_indices[-1])
1079
+ cam_data = self._load_scene_poses(scene_name)
1080
+
1081
+ frames = []
1082
+ for frame_idx in frame_indices:
1083
+ frame_path = os.path.join(self.frames_dir, scene_name, f"{int(frame_idx):04d}.png")
1084
+ img = Image.open(frame_path).convert("RGB")
1085
+ img = self.crop_and_resize(img, *self.get_height_width(img))
1086
+ frames.append(img)
1087
+
1088
+ prompt = row.get("prompt", None)
1089
+ if prompt is None or str(prompt).strip() == "" or str(prompt).lower() == "nan":
1090
+ prompt = self._find_nearest_caption(scene_name, start_frame)
1091
+ else:
1092
+ prompt = str(prompt)
1093
+
1094
+ rt_list_abs = []
1095
+ for frame_idx in frame_indices:
1096
+ key = str(int(frame_idx))
1097
+ if key not in cam_data:
1098
+ raise ValueError(f"Scene {scene_name} missing pose for frame {frame_idx}.")
1099
+ frame_data = cam_data[key]
1100
+ raw_pos = frame_data["position"]
1101
+ pos = [float(p) * self.cam_position_scale for p in raw_pos]
1102
+ rt = self._compute_rt(pos, frame_data["rotation"])
1103
+ rt_list_abs.append(rt)
1104
+
1105
+ rt_list = self._to_relative_rt(rt_list_abs, rt_list_abs[0])
1106
+ pose_indices = list(range(0, len(frame_indices), 4))
1107
+ actions = [rt_list[i] for i in pose_indices]
1108
+ geometry_memory_frames = self._load_geometry_memory_frames(row)
1109
+
1110
+ return {
1111
+ "video": frames,
1112
+ "prompt": prompt,
1113
+ "actions": actions,
1114
+ "video_name": scene_name,
1115
+ "start_frame": start_frame,
1116
+ "end_frame": end_frame,
1117
+ "geometry_memory_frames": geometry_memory_frames,
1118
+ **self._build_condition_context_payload(
1119
+ frames=frames,
1120
+ scene_name=scene_name,
1121
+ start_frame=start_frame,
1122
+ ref_rt=rt_list_abs[0],
1123
+ actions=actions,
1124
+ ),
1125
+ }
1126
+
1127
+ def __getitem__(self, data_id):
1128
+ n = len(self.data)
1129
+ if n == 0:
1130
+ raise RuntimeError("CamVideoDataset has no scenes.")
1131
+ max_attempts = min(64, n)
1132
+ last_error = None
1133
+ for attempt in range(max_attempts):
1134
+ idx = (data_id + attempt) % n
1135
+ data_item = self.data[idx]
1136
+ scene_name = data_item.get("video_name", "?") if isinstance(data_item, dict) else "?"
1137
+ if scene_name in self.invalid_scenes:
1138
+ continue
1139
+ try:
1140
+ return self._try_get_sample(data_item)
1141
+ except (ValueError, FileNotFoundError, KeyError, OSError) as e:
1142
+ self.invalid_scenes.add(scene_name)
1143
+ last_error = e
1144
+ if attempt < 3 or attempt % 8 == 0:
1145
+ print(
1146
+ f"[CamVideoDataset] Skipping invalid scene '{scene_name}' "
1147
+ f"({type(e).__name__}: {e}); attempt {attempt + 1}/{max_attempts}"
1148
+ )
1149
+ continue
1150
+ raise RuntimeError(
1151
+ f"CamVideoDataset: exhausted {max_attempts} attempts starting from index {data_id}; "
1152
+ f"last error: {type(last_error).__name__}: {last_error}"
1153
+ )
1154
+
1155
+ def _build_condition_context_payload(self, frames, scene_name, start_frame, ref_rt, actions):
1156
+ if not self.use_condition_context_frames:
1157
+ return {}
1158
+ payload = {
1159
+ "use_condition_context_frames": False,
1160
+ "condition_frames": [],
1161
+ "condition_frame_indices": [],
1162
+ "condition_source": None,
1163
+ "condition_actions": [],
1164
+ }
1165
+ condition_mode = self._sample_condition_mode()
1166
+ payload["condition_source"] = condition_mode
1167
+ if condition_mode == "text_only":
1168
+ return payload
1169
+ payload["use_condition_context_frames"] = True
1170
+ if self.condition_first_frame:
1171
+ payload["condition_frames"].append(frames[0])
1172
+ payload["condition_frame_indices"].append(start_frame)
1173
+ payload["condition_source"] = "first_frame_only"
1174
+ if self.condition_use_camera_pose and actions:
1175
+ payload["condition_actions"].append(list(actions[0]))
1176
+ if (
1177
+ condition_mode == "first_plus_overlap"
1178
+ and self.condition_history_keyframes
1179
+ and self.num_condition_frames > len(payload["condition_frames"])
1180
+ ):
1181
+ num_extra_conditions = self.num_condition_frames - len(payload["condition_frames"])
1182
+ overlap_payload = self._sample_overlap_conditions(
1183
+ scene_name=scene_name,
1184
+ start_frame=start_frame,
1185
+ ref_rt=ref_rt,
1186
+ target_height=frames[0].size[1],
1187
+ target_width=frames[0].size[0],
1188
+ num_extra_conditions=num_extra_conditions,
1189
+ )
1190
+ if overlap_payload is None:
1191
+ return payload
1192
+ overlap_frames, overlap_indices, overlap_actions = overlap_payload
1193
+ payload["condition_frames"].extend(overlap_frames)
1194
+ payload["condition_frame_indices"].extend(overlap_indices)
1195
+ if self.condition_use_camera_pose:
1196
+ payload["condition_actions"].extend(overlap_actions)
1197
+ payload["condition_source"] = "first_plus_overlap"
1198
+ return payload
1199
+
1200
+ def __len__(self):
1201
+ return len(self.data)
1202
+
1203
+
1204
+ class DiffusionTrainingModule(torch.nn.Module):
1205
+ def __init__(self):
1206
+ super().__init__()
1207
+
1208
+
1209
+ def to(self, *args, **kwargs):
1210
+ for name, model in self.named_children():
1211
+ model.to(*args, **kwargs)
1212
+ return self
1213
+
1214
+
1215
+ def trainable_modules(self):
1216
+ trainable_modules = filter(lambda p: p.requires_grad, self.parameters())
1217
+ return trainable_modules
1218
+
1219
+
1220
+ def trainable_param_names(self):
1221
+ trainable_param_names = list(filter(lambda named_param: named_param[1].requires_grad, self.named_parameters()))
1222
+ trainable_param_names = set([named_param[0] for named_param in trainable_param_names])
1223
+ return trainable_param_names
1224
+
1225
+
1226
+ def add_lora_to_model(self, model, target_modules, lora_rank, lora_alpha=None):
1227
+ if lora_alpha is None:
1228
+ lora_alpha = lora_rank
1229
+ lora_config = LoraConfig(r=lora_rank, lora_alpha=lora_alpha, target_modules=target_modules)
1230
+ model = inject_adapter_in_model(lora_config, model)
1231
+ return model
1232
+
1233
+
1234
+ def export_trainable_state_dict(self, state_dict, remove_prefix=None):
1235
+ trainable_param_names = self.trainable_param_names()
1236
+ state_dict = {name: param for name, param in state_dict.items() if name in trainable_param_names}
1237
+ if remove_prefix is not None:
1238
+ state_dict_ = {}
1239
+ for name, param in state_dict.items():
1240
+ if name.startswith(remove_prefix):
1241
+ name = name[len(remove_prefix):]
1242
+ state_dict_[name] = param
1243
+ state_dict = state_dict_
1244
+ return state_dict
1245
+
1246
+
1247
+
1248
+ class ModelLogger:
1249
+ def __init__(self, output_path, remove_prefix_in_ckpt=None, state_dict_converter=lambda x:x):
1250
+ self.output_path = output_path
1251
+ self.remove_prefix_in_ckpt = remove_prefix_in_ckpt
1252
+ self.state_dict_converter = state_dict_converter
1253
+
1254
+
1255
+ def on_step_end(self, loss):
1256
+ pass
1257
+
1258
+
1259
+ def on_epoch_end(self, accelerator, model, epoch_id):
1260
+ accelerator.wait_for_everyone()
1261
+ if accelerator.is_main_process:
1262
+ state_dict = accelerator.get_state_dict(model)
1263
+ state_dict = accelerator.unwrap_model(model).export_trainable_state_dict(state_dict, remove_prefix=self.remove_prefix_in_ckpt)
1264
+ state_dict = self.state_dict_converter(state_dict)
1265
+ os.makedirs(self.output_path, exist_ok=True)
1266
+ path = os.path.join(self.output_path, f"epoch-{epoch_id}.safetensors")
1267
+ accelerator.save(state_dict, path, safe_serialization=True)
1268
+
1269
+
1270
+
1271
+ def launch_training_task(
1272
+ dataset: torch.utils.data.Dataset,
1273
+ model: DiffusionTrainingModule,
1274
+ model_logger: ModelLogger,
1275
+ optimizer: torch.optim.Optimizer,
1276
+ scheduler: torch.optim.lr_scheduler.LRScheduler,
1277
+ num_epochs: int = 1,
1278
+ gradient_accumulation_steps: int = 1,
1279
+ ):
1280
+ dataloader = torch.utils.data.DataLoader(dataset, shuffle=True, collate_fn=lambda x: x[0], drop_last=True)
1281
+ accelerator = Accelerator(gradient_accumulation_steps=gradient_accumulation_steps)
1282
+ model, optimizer, dataloader, scheduler = accelerator.prepare(model, optimizer, dataloader, scheduler)
1283
+
1284
+ for epoch_id in range(num_epochs):
1285
+ for data in tqdm(dataloader):
1286
+ with accelerator.accumulate(model):
1287
+ optimizer.zero_grad()
1288
+ loss = model(data)
1289
+ accelerator.backward(loss)
1290
+ optimizer.step()
1291
+ model_logger.on_step_end(loss)
1292
+ scheduler.step()
1293
+ model_logger.on_epoch_end(accelerator, model, epoch_id)
1294
+
1295
+ def launch_data_process_task(model: DiffusionTrainingModule, dataset, output_path="./models"):
1296
+ dataloader = torch.utils.data.DataLoader(dataset, shuffle=False, collate_fn=lambda x: x[0], drop_last=True)
1297
+ accelerator = Accelerator()
1298
+ model, dataloader = accelerator.prepare(model, dataloader)
1299
+ os.makedirs(os.path.join(output_path, "data_cache"), exist_ok=True)
1300
+ for data_id, data in enumerate(tqdm(dataloader)):
1301
+ with torch.no_grad():
1302
+ inputs = model.forward_preprocess(data)
1303
+ inputs = {key: inputs[key] for key in model.model_input_keys if key in inputs}
1304
+ torch.save(inputs, os.path.join(output_path, "data_cache", f"{data_id}.pth"))
1305
+
1306
+
1307
+
1308
+ def wan_parser():
1309
+ parser = argparse.ArgumentParser(description="Simple example of a training script.")
1310
+ parser.add_argument("--dataset_base_path", type=str, default="", required=True, help="Base path of the dataset.")
1311
+ parser.add_argument("--dataset_metadata_path", type=str, default=None, help="Path to the metadata file of the dataset.")
1312
+ parser.add_argument("--max_pixels", type=int, default=1280*720, help="Maximum number of pixels per frame, used for dynamic resolution..")
1313
+ parser.add_argument("--height", type=int, default=None, help="Height of images or videos. Leave `height` and `width` empty to enable dynamic resolution.")
1314
+ parser.add_argument("--width", type=int, default=None, help="Width of images or videos. Leave `height` and `width` empty to enable dynamic resolution.")
1315
+ parser.add_argument("--num_frames", type=int, default=81, help="Number of frames per video. Frames are sampled from the video prefix.")
1316
+ parser.add_argument("--data_file_keys", type=str, default="image,video", help="Data file keys in the metadata. Comma-separated.")
1317
+ parser.add_argument("--dataset_repeat", type=int, default=1, help="Number of times to repeat the dataset per epoch.")
1318
+ parser.add_argument("--model_paths", type=str, default=None, help="Paths to load models. In JSON format.")
1319
+ parser.add_argument("--model_id_with_origin_paths", type=str, default=None, help="Model ID with origin paths, e.g., Wan-AI/Wan2.1-T2V-1.3B:diffusion_pytorch_model*.safetensors. Comma-separated.")
1320
+ parser.add_argument("--learning_rate", type=float, default=1e-4, help="Learning rate.")
1321
+ parser.add_argument("--num_epochs", type=int, default=1, help="Number of epochs.")
1322
+ parser.add_argument("--output_path", type=str, default="./models", help="Output save path.")
1323
+ parser.add_argument("--remove_prefix_in_ckpt", type=str, default="pipe.dit.", help="Remove prefix in ckpt.")
1324
+ parser.add_argument("--trainable_models", type=str, default=None, help="Models to train, e.g., dit, vae, text_encoder.")
1325
+ parser.add_argument("--lora_base_model", type=str, default=None, help="Which model LoRA is added to.")
1326
+ parser.add_argument("--lora_target_modules", type=str, default="q,k,v,o,ffn.0,ffn.2", help="Which layers LoRA is added to.")
1327
+ parser.add_argument("--lora_rank", type=int, default=32, help="Rank of LoRA.")
1328
+ parser.add_argument("--extra_inputs", default=None, help="Additional model inputs, comma-separated.")
1329
+ parser.add_argument("--use_gradient_checkpointing_offload", default=False, action="store_true", help="Whether to offload gradient checkpointing to CPU memory.")
1330
+ parser.add_argument("--gradient_accumulation_steps", type=int, default=1, help="Gradient accumulation steps.")
1331
+ parser.add_argument("--use_condition_context_frames", default=False, action="store_true", help="Enable appended clean condition latents.")
1332
+ parser.add_argument("--condition_first_frame", default=False, action="store_true", help="Use the current clip first frame as a clean condition frame.")
1333
+ parser.add_argument("--condition_history_keyframes", default=False, action="store_true", help="Use overlap-based keyframes as conditions.")
1334
+ parser.add_argument("--condition_use_camera_pose", default=True, action="store_true", help="Inject camera pose for condition frames.")
1335
+ parser.add_argument("--num_condition_frames", type=int, default=1, help="Number of condition frames.")
1336
+ parser.add_argument("--condition_frame_mode", type=str, default="first_frame_only", help="Condition frame selection mode.")
1337
+ parser.add_argument("--overlap_labels_root", type=str, default=None, help="Root dir for overlap label JSONs.")
1338
+ parser.add_argument("--condition_t2v_ratio", type=float, default=0.10, help="Ratio of text-only condition samples.")
1339
+ parser.add_argument("--condition_i2v_ratio", type=float, default=0.10, help="Ratio of first-frame-only condition samples.")
1340
+ return parser
1341
+
1342
+
1343
+
1344
+ def flux_parser():
1345
+ parser = argparse.ArgumentParser(description="Simple example of a training script.")
1346
+ parser.add_argument("--dataset_base_path", type=str, default="", required=True, help="Base path of the dataset.")
1347
+ parser.add_argument("--dataset_metadata_path", type=str, default=None, help="Path to the metadata file of the dataset.")
1348
+ parser.add_argument("--max_pixels", type=int, default=1024*1024, help="Maximum number of pixels per frame, used for dynamic resolution..")
1349
+ parser.add_argument("--height", type=int, default=None, help="Height of images. Leave `height` and `width` empty to enable dynamic resolution.")
1350
+ parser.add_argument("--width", type=int, default=None, help="Width of images. Leave `height` and `width` empty to enable dynamic resolution.")
1351
+ parser.add_argument("--data_file_keys", type=str, default="image", help="Data file keys in the metadata. Comma-separated.")
1352
+ parser.add_argument("--dataset_repeat", type=int, default=1, help="Number of times to repeat the dataset per epoch.")
1353
+ parser.add_argument("--model_paths", type=str, default=None, help="Paths to load models. In JSON format.")
1354
+ parser.add_argument("--model_id_with_origin_paths", type=str, default=None, help="Model ID with origin paths, e.g., Wan-AI/Wan2.1-T2V-1.3B:diffusion_pytorch_model*.safetensors. Comma-separated.")
1355
+ parser.add_argument("--learning_rate", type=float, default=1e-4, help="Learning rate.")
1356
+ parser.add_argument("--num_epochs", type=int, default=1, help="Number of epochs.")
1357
+ parser.add_argument("--output_path", type=str, default="./models", help="Output save path.")
1358
+ parser.add_argument("--remove_prefix_in_ckpt", type=str, default="pipe.dit.", help="Remove prefix in ckpt.")
1359
+ parser.add_argument("--trainable_models", type=str, default=None, help="Models to train, e.g., dit, vae, text_encoder.")
1360
+ parser.add_argument("--lora_base_model", type=str, default=None, help="Which model LoRA is added to.")
1361
+ parser.add_argument("--lora_target_modules", type=str, default="q,k,v,o,ffn.0,ffn.2", help="Which layers LoRA is added to.")
1362
+ parser.add_argument("--lora_rank", type=int, default=32, help="Rank of LoRA.")
1363
+ parser.add_argument("--extra_inputs", default=None, help="Additional model inputs, comma-separated.")
1364
+ parser.add_argument("--align_to_opensource_format", default=False, action="store_true", help="Whether to align the lora format to opensource format. Only for DiT's LoRA.")
1365
+ parser.add_argument("--use_gradient_checkpointing", default=False, action="store_true", help="Whether to use gradient checkpointing.")
1366
+ parser.add_argument("--use_gradient_checkpointing_offload", default=False, action="store_true", help="Whether to offload gradient checkpointing to CPU memory.")
1367
+ parser.add_argument("--gradient_accumulation_steps", type=int, default=1, help="Gradient accumulation steps.")
1368
+ return parser
code/diffsynth/trainers/utils.py.p0bak-getitem ADDED
@@ -0,0 +1,1367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import imageio, os, torch, warnings, torchvision, argparse, json, random
2
+ from peft import LoraConfig, inject_adapter_in_model
3
+ from PIL import Image
4
+ import pandas as pd
5
+ from tqdm import tqdm
6
+ from accelerate import Accelerator
7
+
8
+
9
+
10
+ class ImageDataset(torch.utils.data.Dataset):
11
+ def __init__(
12
+ self,
13
+ base_path=None, metadata_path=None,
14
+ max_pixels=1920*1080, height=None, width=None,
15
+ height_division_factor=16, width_division_factor=16,
16
+ data_file_keys=("image",),
17
+ image_file_extension=("jpg", "jpeg", "png", "webp"),
18
+ repeat=1,
19
+ args=None,
20
+ ):
21
+ if args is not None:
22
+ base_path = args.dataset_base_path
23
+ metadata_path = args.dataset_metadata_path
24
+ height = args.height
25
+ width = args.width
26
+ max_pixels = args.max_pixels
27
+ data_file_keys = args.data_file_keys.split(",")
28
+ repeat = args.dataset_repeat
29
+
30
+ self.base_path = base_path
31
+ self.max_pixels = max_pixels
32
+ self.height = height
33
+ self.width = width
34
+ self.height_division_factor = height_division_factor
35
+ self.width_division_factor = width_division_factor
36
+ self.data_file_keys = data_file_keys
37
+ self.image_file_extension = image_file_extension
38
+ self.repeat = repeat
39
+
40
+ if height is not None and width is not None:
41
+ print("Height and width are fixed. Setting `dynamic_resolution` to False.")
42
+ self.dynamic_resolution = False
43
+ elif height is None and width is None:
44
+ print("Height and width are none. Setting `dynamic_resolution` to True.")
45
+ self.dynamic_resolution = True
46
+
47
+ if metadata_path is None:
48
+ print("No metadata. Trying to generate it.")
49
+ metadata = self.generate_metadata(base_path)
50
+ print(f"{len(metadata)} lines in metadata.")
51
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
52
+ elif metadata_path.endswith(".json"):
53
+ with open(metadata_path, "r") as f:
54
+ metadata = json.load(f)
55
+ self.data = metadata
56
+ else:
57
+ metadata = pd.read_csv(metadata_path)
58
+ # Ensure prompt column is string type to avoid float conversion for NaN values
59
+ if 'prompt' in metadata.columns:
60
+ metadata['prompt'] = metadata['prompt'].astype(str)
61
+ # Replace 'nan' string (from NaN) with empty string
62
+ metadata['prompt'] = metadata['prompt'].replace('nan', '')
63
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
64
+
65
+
66
+ def generate_metadata(self, folder):
67
+ image_list, prompt_list = [], []
68
+ file_set = set(os.listdir(folder))
69
+ for file_name in file_set:
70
+ if "." not in file_name:
71
+ continue
72
+ file_ext_name = file_name.split(".")[-1].lower()
73
+ file_base_name = file_name[:-len(file_ext_name)-1]
74
+ if file_ext_name not in self.image_file_extension:
75
+ continue
76
+ prompt_file_name = file_base_name + ".txt"
77
+ if prompt_file_name not in file_set:
78
+ continue
79
+ with open(os.path.join(folder, prompt_file_name), "r", encoding="utf-8") as f:
80
+ prompt = f.read().strip()
81
+ image_list.append(file_name)
82
+ prompt_list.append(prompt)
83
+ metadata = pd.DataFrame()
84
+ metadata["image"] = image_list
85
+ metadata["prompt"] = prompt_list
86
+ return metadata
87
+
88
+
89
+ def crop_and_resize(self, image, target_height, target_width):
90
+ width, height = image.size
91
+ scale = max(target_width / width, target_height / height)
92
+ image = torchvision.transforms.functional.resize(
93
+ image,
94
+ (round(height*scale), round(width*scale)),
95
+ interpolation=torchvision.transforms.InterpolationMode.BILINEAR
96
+ )
97
+ image = torchvision.transforms.functional.center_crop(image, (target_height, target_width))
98
+ return image
99
+
100
+
101
+ def get_height_width(self, image):
102
+ if self.dynamic_resolution:
103
+ width, height = image.size
104
+ if width * height > self.max_pixels:
105
+ scale = (width * height / self.max_pixels) ** 0.5
106
+ height, width = int(height / scale), int(width / scale)
107
+ height = height // self.height_division_factor * self.height_division_factor
108
+ width = width // self.width_division_factor * self.width_division_factor
109
+ else:
110
+ height, width = self.height, self.width
111
+ return height, width
112
+
113
+
114
+ def load_image(self, file_path):
115
+ image = Image.open(file_path).convert("RGB")
116
+ image = self.crop_and_resize(image, *self.get_height_width(image))
117
+ return image
118
+
119
+
120
+ def load_data(self, file_path):
121
+ return self.load_image(file_path)
122
+
123
+
124
+ def __getitem__(self, data_id):
125
+ data = self.data[data_id % len(self.data)].copy()
126
+ for key in self.data_file_keys:
127
+ if key in data:
128
+ path = os.path.join(self.base_path, data[key])
129
+ data[key] = self.load_data(path)
130
+ if data[key] is None:
131
+ warnings.warn(f"cannot load file {data[key]}.")
132
+ return None
133
+ return data
134
+
135
+
136
+ def __len__(self):
137
+ return len(self.data) * self.repeat
138
+
139
+
140
+
141
+ class VideoDataset(torch.utils.data.Dataset):
142
+ def __init__(
143
+ self,
144
+ base_path=None, metadata_path=None,
145
+ num_frames=81,
146
+ time_division_factor=4, time_division_remainder=1,
147
+ max_pixels=1920*1080, height=None, width=None,
148
+ height_division_factor=16, width_division_factor=16,
149
+ data_file_keys=("video",),
150
+ image_file_extension=("jpg", "jpeg", "png", "webp"),
151
+ video_file_extension=("mp4", "avi", "mov", "wmv", "mkv", "flv", "webm"),
152
+ repeat=1,
153
+ args=None,
154
+ action_base_path=None,
155
+ enable_icl=False,
156
+ icl_num_examples=2,
157
+ icl_context_frames=8,
158
+ ):
159
+ if args is not None:
160
+ base_path = args.dataset_base_path
161
+ metadata_path = args.dataset_metadata_path
162
+ height = args.height
163
+ width = args.width
164
+ max_pixels = args.max_pixels
165
+ num_frames = args.num_frames
166
+ data_file_keys = args.data_file_keys.split(",")
167
+ repeat = args.dataset_repeat
168
+ # In-context learning parameters
169
+ if hasattr(args, 'enable_icl'):
170
+ enable_icl = args.enable_icl
171
+ if hasattr(args, 'icl_num_examples'):
172
+ icl_num_examples = args.icl_num_examples
173
+ if hasattr(args, 'icl_context_frames'):
174
+ icl_context_frames = args.icl_context_frames
175
+
176
+ self.base_path = base_path
177
+ self.num_frames = num_frames
178
+ self.time_division_factor = time_division_factor
179
+ self.time_division_remainder = time_division_remainder
180
+ self.max_pixels = max_pixels
181
+ self.height = height
182
+ self.width = width
183
+ self.height_division_factor = height_division_factor
184
+ self.width_division_factor = width_division_factor
185
+ self.data_file_keys = data_file_keys
186
+ self.image_file_extension = image_file_extension
187
+ self.video_file_extension = video_file_extension
188
+ self.repeat = repeat
189
+
190
+ # In-context learning parameters
191
+ self.enable_icl = enable_icl
192
+ self.icl_num_examples = icl_num_examples
193
+ self.icl_context_frames = icl_context_frames
194
+
195
+ if height is not None and width is not None:
196
+ print("Height and width are fixed. Setting `dynamic_resolution` to False.")
197
+ self.dynamic_resolution = False
198
+ elif height is None and width is None:
199
+ print("Height and width are none. Setting `dynamic_resolution` to True.")
200
+ self.dynamic_resolution = True
201
+
202
+ if metadata_path is None:
203
+ print("No metadata. Trying to generate it.")
204
+ metadata = self.generate_metadata(base_path)
205
+ print(f"{len(metadata)} lines in metadata.")
206
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
207
+ elif metadata_path.endswith(".json"):
208
+ with open(metadata_path, "r") as f:
209
+ metadata = json.load(f)
210
+ self.data = metadata
211
+ else:
212
+ metadata = pd.read_csv(metadata_path)
213
+ # Ensure prompt column is string type to avoid float conversion for NaN values
214
+ if 'prompt' in metadata.columns:
215
+ metadata['prompt'] = metadata['prompt'].astype(str)
216
+ # Replace 'nan' string (from NaN) with empty string
217
+ metadata['prompt'] = metadata['prompt'].replace('nan', '')
218
+
219
+ # CRITICAL FIX: Clean prompt - remove video path prefix if present
220
+ # Some CSV prompts start with "video_name.mp4 " prefix, which should be removed
221
+ def clean_prompt(prompt_str):
222
+ if not isinstance(prompt_str, str) or not prompt_str:
223
+ return prompt_str
224
+ # Check if prompt starts with a video path (contains .mp4 or /)
225
+ # Pattern: "VideoName/1234_5678.mp4 " or "VideoName.mp4 "
226
+ import re
227
+ # Match pattern: word/word.mp4 or word.mp4 at the start, followed by space
228
+ pattern = r'^[A-Za-z0-9_]+(/[A-Za-z0-9_]+)?\.mp4\s+'
229
+ cleaned = re.sub(pattern, '', prompt_str)
230
+ # Also handle truncated prompts ending with "..."
231
+ if cleaned.endswith('...'):
232
+ cleaned = cleaned[:-3].rstrip()
233
+ return cleaned.strip()
234
+
235
+ metadata['prompt'] = metadata['prompt'].apply(clean_prompt)
236
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
237
+
238
+ self.action_base_path = action_base_path
239
+
240
+ if self.enable_icl:
241
+ print(f"In-context learning enabled: {icl_num_examples} examples, {icl_context_frames} context frames each")
242
+
243
+
244
+ def generate_metadata(self, folder):
245
+ video_list, prompt_list = [], []
246
+ file_set = set(os.listdir(folder))
247
+ for file_name in file_set:
248
+ if "." not in file_name:
249
+ continue
250
+ file_ext_name = file_name.split(".")[-1].lower()
251
+ file_base_name = file_name[:-len(file_ext_name)-1]
252
+ if file_ext_name not in self.image_file_extension and file_ext_name not in self.video_file_extension:
253
+ continue
254
+ prompt_file_name = file_base_name + ".txt"
255
+ if prompt_file_name not in file_set:
256
+ continue
257
+ with open(os.path.join(folder, prompt_file_name), "r", encoding="utf-8") as f:
258
+ prompt = f.read().strip()
259
+ video_list.append(file_name)
260
+ prompt_list.append(prompt)
261
+ metadata = pd.DataFrame()
262
+ metadata["video"] = video_list
263
+ metadata["prompt"] = prompt_list
264
+ return metadata
265
+
266
+
267
+ def crop_and_resize(self, image, target_height, target_width):
268
+ width, height = image.size
269
+ scale = max(target_width / width, target_height / height)
270
+ image = torchvision.transforms.functional.resize(
271
+ image,
272
+ (round(height*scale), round(width*scale)),
273
+ interpolation=torchvision.transforms.InterpolationMode.BILINEAR
274
+ )
275
+ image = torchvision.transforms.functional.center_crop(image, (target_height, target_width))
276
+ return image
277
+
278
+
279
+ def get_height_width(self, image):
280
+ if self.dynamic_resolution:
281
+ width, height = image.size
282
+ if width * height > self.max_pixels:
283
+ scale = (width * height / self.max_pixels) ** 0.5
284
+ height, width = int(height / scale), int(width / scale)
285
+ height = height // self.height_division_factor * self.height_division_factor
286
+ width = width // self.width_division_factor * self.width_division_factor
287
+ else:
288
+ height, width = self.height, self.width
289
+ return height, width
290
+
291
+
292
+ def get_num_frames(self, reader):
293
+ num_frames = self.num_frames
294
+ if int(reader.count_frames()) < num_frames:
295
+ num_frames = int(reader.count_frames())
296
+ while num_frames > 1 and num_frames % self.time_division_factor != self.time_division_remainder:
297
+ num_frames -= 1
298
+ return num_frames
299
+
300
+
301
+ def load_video(self, file_path):
302
+ reader = imageio.get_reader(file_path)
303
+ num_frames = self.get_num_frames(reader)
304
+ frames = []
305
+ for frame_id in range(num_frames):
306
+ frame = reader.get_data(frame_id)
307
+ frame = Image.fromarray(frame)
308
+ frame = self.crop_and_resize(frame, *self.get_height_width(frame))
309
+ frames.append(frame)
310
+ reader.close()
311
+ return frames
312
+
313
+
314
+ def load_image(self, file_path):
315
+ image = Image.open(file_path).convert("RGB")
316
+ image = self.crop_and_resize(image, *self.get_height_width(image))
317
+ frames = [image]
318
+ return frames
319
+
320
+
321
+ def is_image(self, file_path):
322
+ file_ext_name = file_path.split(".")[-1]
323
+ return file_ext_name.lower() in self.image_file_extension
324
+
325
+
326
+ def is_video(self, file_path):
327
+ file_ext_name = file_path.split(".")[-1]
328
+ return file_ext_name.lower() in self.video_file_extension
329
+
330
+
331
+ def load_data(self, file_path):
332
+ # Handle multiple frame paths separated by '|' (for frame sequences)
333
+ if '|' in str(file_path):
334
+ # Split the path by '|' to get individual frame paths
335
+ frame_paths = str(file_path).split('|')
336
+ frames = []
337
+
338
+ # Get base_path (dataset root)
339
+ if not hasattr(self, 'base_path') or not self.base_path:
340
+ warnings.warn(f"Cannot determine base directory for frame sequence: {file_path}")
341
+ return None
342
+
343
+ base_dir = self.base_path # This is the dataset root
344
+
345
+ # Check the first path to determine the format
346
+ first_frame = frame_paths[0].strip() if frame_paths else ""
347
+
348
+ # If first frame is already an absolute path (from __getitem__ joining),
349
+ # extract the base directory from it
350
+ if os.path.isabs(first_frame):
351
+ # Extract base directory from first frame path
352
+ # First frame format: /path/to/dataset/frames/video_name/frame.png
353
+ # We need to get /path/to/dataset
354
+ parts = first_frame.split(os.sep)
355
+ # Find 'frames' in the path and get everything before it
356
+ if 'frames' in parts:
357
+ frames_idx = parts.index('frames')
358
+ base_dir = os.sep.join(parts[:frames_idx])
359
+ else:
360
+ # Fallback: use self.base_path
361
+ base_dir = self.base_path
362
+
363
+ for frame_path in frame_paths:
364
+ frame_path = frame_path.strip()
365
+ if not frame_path:
366
+ continue
367
+
368
+ # Construct full path
369
+ if os.path.isabs(frame_path):
370
+ # Already absolute path (from __getitem__)
371
+ full_frame_path = frame_path
372
+ else:
373
+ # Relative path - need to construct full path
374
+ # Remove 'frames/' prefix if present (we'll add it consistently)
375
+ if frame_path.startswith('frames/'):
376
+ frame_path = frame_path[7:] # Remove 'frames/' prefix
377
+
378
+ # Always join with base_dir + 'frames/' since base_dir is dataset root
379
+ full_frame_path = os.path.join(base_dir, 'frames', frame_path)
380
+
381
+ # Load individual frame
382
+ if os.path.exists(full_frame_path):
383
+ if self.is_image(full_frame_path):
384
+ frame_data = self.load_image(full_frame_path)
385
+ if frame_data:
386
+ frames.extend(frame_data)
387
+ else:
388
+ warnings.warn(f"Frame is not an image: {full_frame_path}")
389
+ else:
390
+ warnings.warn(f"Frame not found: {full_frame_path}")
391
+
392
+ if frames:
393
+ return frames
394
+ else:
395
+ warnings.warn(f"No frames loaded from sequence: {file_path}")
396
+ return None
397
+
398
+ # Handle single file (image or video)
399
+ if self.is_image(file_path):
400
+ return self.load_image(file_path)
401
+ elif self.is_video(file_path):
402
+ return self.load_video(file_path)
403
+ else:
404
+ return None
405
+
406
+
407
+ def __getitem__(self, data_id):
408
+ data = self.data[data_id % len(self.data)].copy()
409
+ for key in self.data_file_keys:
410
+ if key in ["video_name", "start_frame", "end_frame"]:
411
+ if "actions" in data:
412
+ continue
413
+ try:
414
+ video_name = data.get("video_name")
415
+ if video_name is None:
416
+ warnings.warn(f"video_name is missing in metadata for data_id {data_id}. Skipping action loading.")
417
+ continue
418
+
419
+ if video_name.endswith(".mp4"):
420
+ video_name = ".".join(video_name.split(".")[:-1])
421
+ if "_" in video_name:
422
+ video_name = "_".join(video_name.split("_")[:4])
423
+
424
+ import json
425
+ json_path = os.path.join(self.action_base_path, video_name + ".json")
426
+
427
+ # Check if action file exists
428
+ if not os.path.exists(json_path):
429
+ warnings.warn(f"Action file does not exist: {json_path}. Skipping action loading for data_id {data_id}.")
430
+ continue
431
+
432
+ start_frame = data.get("start_frame")
433
+ end_frame = data.get("end_frame")
434
+ if start_frame is None or end_frame is None:
435
+ warnings.warn(f"start_frame or end_frame is missing in metadata for data_id {data_id}. Skipping action loading.")
436
+ continue
437
+
438
+ json_data = json.load(open(json_path, "r"))['actions']
439
+ actions = []
440
+ current_yaw = 0.0
441
+ for frame_id in range(start_frame+1, end_frame+1):
442
+ frame_str = str(frame_id)
443
+ if frame_str not in json_data:
444
+ warnings.warn(f"Frame {frame_id} not found in action file {json_path}. Skipping this frame.")
445
+ continue
446
+
447
+ action = json_data[frame_str]
448
+ new_action = [0.0] * (2 + 2 + 3 + 1 + 2)
449
+ if action['ws'] == 1:
450
+ new_action[0] = 1
451
+ elif action['ws'] == 2:
452
+ new_action[1] = 1
453
+
454
+ if action['ad'] == 1:
455
+ new_action[2] = 1
456
+ elif action['ad'] == 2:
457
+ new_action[3] = 1
458
+
459
+ if action['scs'] == 1 and action.get("jump_invalid", 0) == 0:
460
+ new_action[4] = 1
461
+ elif action['scs'] == 2:
462
+ new_action[5] = 1
463
+ elif action['scs'] == 3:
464
+ new_action[6] = 1
465
+
466
+ if action.get('collision', 0) == 1:
467
+ new_action[7] = 1
468
+ new_action[0] = 0
469
+ new_action[1] = 0
470
+ new_action[2] = 0
471
+ new_action[3] = 0
472
+
473
+ pre_pitch = action.get('pre_pitch', 0.0)
474
+ current_pitch = pre_pitch + action.get('pitch_delta', 0.0) * 15.0
475
+ current_yaw += action.get('yaw_delta', 0.0) * 15.0
476
+ new_action[8] = current_pitch
477
+ new_action[9] = current_yaw
478
+
479
+ actions.append(new_action)
480
+ data["actions"] = actions
481
+ except Exception as e:
482
+ warnings.warn(f"Exception while loading actions for data_id {data_id}: {e}. Continuing without actions.")
483
+ # Don't return None, just continue without actions
484
+ continue
485
+ elif key == "video":
486
+ # Check if data[key] exists and is not None
487
+ if key not in data or data[key] is None:
488
+ warnings.warn(f"Video key '{key}' is missing or None in metadata for data_id {data_id}. Skipping this sample.")
489
+ return None
490
+
491
+ # Handle frame sequences (paths with '|' separator)
492
+ video_path_str = str(data[key])
493
+ if '|' in video_path_str:
494
+ # For frame sequences, pass the full path string to load_data
495
+ # load_data will handle splitting and loading individual frames
496
+ path = os.path.join(self.base_path, video_path_str)
497
+ # Don't check path existence here for frame sequences
498
+ # load_data will handle individual frame loading
499
+ else:
500
+ path = os.path.join(self.base_path, data[key])
501
+ # Check if path exists (only for single files)
502
+ if not os.path.exists(path):
503
+ warnings.warn(f"Video file does not exist: {path}. Skipping this sample.")
504
+ return None
505
+ try:
506
+ data[key] = self.load_data(path)
507
+ if data[key] is None:
508
+ warnings.warn(f"Failed to load video file: {path}. load_data returned None.")
509
+ return None
510
+ except Exception as e:
511
+ warnings.warn(f"Exception while loading video file {path}: {e}. Skipping this sample.")
512
+ return None
513
+
514
+ # In-context learning: sample context examples from dataset
515
+ if self.enable_icl and len(self.data) > 1:
516
+ context_frames_list = []
517
+ context_actions_list = []
518
+
519
+ # Sample random examples from dataset (excluding current one)
520
+ current_idx = data_id % len(self.data)
521
+ candidate_indices = [i for i in range(len(self.data)) if i != current_idx]
522
+ if len(candidate_indices) > 0:
523
+ num_samples = min(self.icl_num_examples, len(candidate_indices))
524
+ sampled_indices = random.sample(candidate_indices, num_samples)
525
+
526
+ for sample_idx in sampled_indices:
527
+ sample_data = self.data[sample_idx].copy()
528
+ # Load video for context
529
+ if "video" in self.data_file_keys and "video" in sample_data:
530
+ video_path = os.path.join(self.base_path, sample_data["video"])
531
+ sample_video = self.load_data(video_path)
532
+ if sample_video is not None and len(sample_video) >= self.icl_context_frames:
533
+ # Sample context_frames from the video
534
+ start_idx = random.randint(0, max(0, len(sample_video) - self.icl_context_frames))
535
+ context_frames = sample_video[start_idx:start_idx + self.icl_context_frames]
536
+ context_frames_list.extend(context_frames)
537
+
538
+ # Load corresponding actions if available
539
+ if self.action_base_path is not None and "video_name" in sample_data:
540
+ try:
541
+ sample_video_name = sample_data["video_name"]
542
+ if sample_video_name.endswith(".mp4"):
543
+ sample_video_name = ".".join(sample_video_name.split(".")[:-1])
544
+ if "_" in sample_video_name:
545
+ sample_video_name = "_".join(sample_video_name.split("_")[:4])
546
+ sample_json_path = os.path.join(self.action_base_path, sample_video_name + ".json")
547
+ if os.path.exists(sample_json_path):
548
+ sample_json_data = json.load(open(sample_json_path, "r"))['actions']
549
+ sample_start_frame = sample_data.get("start_frame", 0)
550
+ sample_end_frame = sample_data.get("end_frame", len(sample_video))
551
+
552
+ # Get actions for the context frames
553
+ context_actions = []
554
+ context_yaw = 0.0
555
+ for frame_idx in range(sample_start_frame + start_idx + 1,
556
+ min(sample_start_frame + start_idx + self.icl_context_frames + 1, sample_end_frame + 1)):
557
+ if str(frame_idx) in sample_json_data:
558
+ action = sample_json_data[str(frame_idx)]
559
+ new_action = [0.0] * (2 + 2 + 3 + 1 + 2)
560
+ if action['ws'] == 1:
561
+ new_action[0] = 1
562
+ elif action['ws'] == 2:
563
+ new_action[1] = 1
564
+ if action['ad'] == 1:
565
+ new_action[2] = 1
566
+ elif action['ad'] == 2:
567
+ new_action[3] = 1
568
+ if action['scs'] == 1 and action.get("jump_invalid", 0) == 0:
569
+ new_action[4] = 1
570
+ elif action['scs'] == 2:
571
+ new_action[5] = 1
572
+ elif action['scs'] == 3:
573
+ new_action[6] = 1
574
+ if action.get('collision', 0) == 1:
575
+ new_action[7] = 1
576
+ new_action[0] = 0
577
+ new_action[1] = 0
578
+ new_action[2] = 0
579
+ new_action[3] = 0
580
+ pre_pitch = action.get('pre_pitch', 0.0)
581
+ current_pitch = pre_pitch + action.get('pitch_delta', 0.0) * 15.0
582
+ context_yaw += action.get('yaw_delta', 0.0) * 15.0
583
+ new_action[8] = current_pitch
584
+ new_action[9] = context_yaw
585
+ context_actions.append(new_action)
586
+ context_actions_list.extend(context_actions[:len(context_frames)])
587
+ except Exception as e:
588
+ # If loading actions fails, just skip
589
+ pass
590
+
591
+ if context_frames_list:
592
+ data["context_frames"] = context_frames_list
593
+ if context_actions_list and len(context_actions_list) == len(context_frames_list):
594
+ data["context_actions"] = context_actions_list
595
+
596
+ return data
597
+
598
+
599
+ def __len__(self):
600
+ return len(self.data) * self.repeat
601
+
602
+ @staticmethod
603
+ def get_one_hot(action, range=2):
604
+ one_hot = [0] * (range + 1)
605
+ one_hot[action] = 1
606
+ return one_hot
607
+
608
+
609
+
610
+ import numpy as np
611
+
612
+
613
+ class CamVideoDataset(torch.utils.data.Dataset):
614
+ """Dataset for Context-as-Memory camera pose conditioned training (ported from VWM).
615
+
616
+ Loads 81 PNG frames from UE scenes with random temporal cropping and extracts
617
+ corresponding camera poses as 12-dim relative RT vectors subsampled to match
618
+ the 21 latent frames.
619
+ """
620
+ def __init__(
621
+ self,
622
+ base_path=None, metadata_path=None,
623
+ num_frames=81,
624
+ height=None, width=None,
625
+ max_pixels=1920*1080,
626
+ height_division_factor=16, width_division_factor=16,
627
+ repeat=1,
628
+ args=None,
629
+ cam_position_scale=None,
630
+ ):
631
+ if args is not None:
632
+ base_path = args.dataset_base_path
633
+ metadata_path = getattr(args, "dataset_metadata_path", metadata_path)
634
+ height = args.height
635
+ width = args.width
636
+ max_pixels = args.max_pixels
637
+ num_frames = args.num_frames
638
+ repeat = args.dataset_repeat
639
+ cam_position_scale = getattr(args, "cam_position_scale", 0.01)
640
+ self.use_condition_context_frames = getattr(args, "use_condition_context_frames", False)
641
+ self.condition_first_frame = getattr(args, "condition_first_frame", False)
642
+ self.condition_history_keyframes = getattr(args, "condition_history_keyframes", False)
643
+ self.condition_use_camera_pose = getattr(args, "condition_use_camera_pose", True)
644
+ self.num_condition_frames = getattr(args, "num_condition_frames", 1)
645
+ self.condition_frame_mode = getattr(args, "condition_frame_mode", "first_frame_only")
646
+ self.overlap_labels_root = getattr(args, "overlap_labels_root", None)
647
+ self.condition_t2v_ratio = getattr(args, "condition_t2v_ratio", 0.10)
648
+ self.condition_i2v_ratio = getattr(args, "condition_i2v_ratio", 0.10)
649
+ self.use_geometry_spatial_memory = getattr(args, "use_geometry_spatial_memory", False)
650
+ self.geometry_memory_column = getattr(args, "geometry_memory_column", "geometry_memory")
651
+ self.geometry_memory_root = getattr(args, "geometry_memory_root", None)
652
+ else:
653
+ self.use_condition_context_frames = False
654
+ self.condition_first_frame = False
655
+ self.condition_history_keyframes = False
656
+ self.condition_use_camera_pose = True
657
+ self.num_condition_frames = 1
658
+ self.condition_frame_mode = "first_frame_only"
659
+ self.overlap_labels_root = None
660
+ self.condition_t2v_ratio = 0.10
661
+ self.condition_i2v_ratio = 0.10
662
+ self.use_geometry_spatial_memory = False
663
+ self.geometry_memory_column = "geometry_memory"
664
+ self.geometry_memory_root = None
665
+
666
+ if cam_position_scale is None:
667
+ cam_position_scale = 0.01
668
+ self.cam_position_scale = float(cam_position_scale)
669
+
670
+ self.base_path = base_path
671
+ self.frames_dir = os.path.join(base_path, "frames")
672
+ self.jsons_dir = os.path.join(base_path, "jsons")
673
+ self.num_frames = num_frames
674
+ self.max_pixels = max_pixels
675
+ self.height = height
676
+ self.width = width
677
+ self.height_division_factor = height_division_factor
678
+ self.width_division_factor = width_division_factor
679
+ self.repeat = repeat
680
+
681
+ if height is not None and width is not None:
682
+ self.dynamic_resolution = False
683
+ else:
684
+ self.dynamic_resolution = True
685
+
686
+ if metadata_path is None:
687
+ print("No metadata. Trying to generate it.")
688
+ metadata = self.generate_metadata(base_path)
689
+ print(f"{len(metadata)} lines in metadata.")
690
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
691
+ elif metadata_path.endswith(".json"):
692
+ with open(metadata_path, "r") as f:
693
+ metadata = json.load(f)
694
+ self.data = metadata
695
+ else:
696
+ metadata = pd.read_csv(metadata_path)
697
+ # Ensure prompt column is string type to avoid float conversion for NaN values
698
+ if 'prompt' in metadata.columns:
699
+ metadata['prompt'] = metadata['prompt'].astype(str)
700
+ # Replace 'nan' string (from NaN) with empty string
701
+ metadata['prompt'] = metadata['prompt'].replace('nan', '')
702
+
703
+ # CRITICAL FIX: Clean prompt - remove video path prefix if present
704
+ # Some CSV prompts start with "video_name.mp4 " prefix, which should be removed
705
+ def clean_prompt(prompt_str):
706
+ if not isinstance(prompt_str, str) or not prompt_str:
707
+ return prompt_str
708
+ # Check if prompt starts with a video path (contains .mp4 or /)
709
+ # Pattern: "VideoName/1234_5678.mp4 " or "VideoName.mp4 "
710
+ import re
711
+ # Match pattern: word/word.mp4 or word.mp4 at the start, followed by space
712
+ pattern = r'^[A-Za-z0-9_]+(/[A-Za-z0-9_]+)?\.mp4\s+'
713
+ cleaned = re.sub(pattern, '', prompt_str)
714
+ # Also handle truncated prompts ending with "..."
715
+ if cleaned.endswith('...'):
716
+ cleaned = cleaned[:-3].rstrip()
717
+ return cleaned.strip()
718
+
719
+ metadata['prompt'] = metadata['prompt'].apply(clean_prompt)
720
+ self.data = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
721
+
722
+ captions_path = os.path.join(base_path, "captions.txt")
723
+ self.scene_captions = {}
724
+ with open(captions_path, "r") as f:
725
+ for line in f:
726
+ parts = line.strip().split("\t", 1)
727
+ if len(parts) < 2:
728
+ continue
729
+ clip_path, caption = parts
730
+ scene_name = "/".join(clip_path.split("/")[:-1])
731
+ fname = clip_path.split("/")[-1].replace(".mp4", "")
732
+ clip_start = int(fname.split("_")[0])
733
+ if scene_name not in self.scene_captions:
734
+ self.scene_captions[scene_name] = []
735
+ self.scene_captions[scene_name].append((clip_start, caption))
736
+
737
+ for scene_name in self.scene_captions:
738
+ self.scene_captions[scene_name].sort(key=lambda x: x[0])
739
+
740
+ self.scene_names = sorted(self.scene_captions.keys())
741
+ self.metadata_rows = []
742
+ if metadata_path and os.path.isfile(metadata_path):
743
+ metadata = pd.read_csv(metadata_path)
744
+ if "prompt" in metadata.columns:
745
+ metadata["prompt"] = metadata["prompt"].astype(str)
746
+ self.metadata_rows = [metadata.iloc[i].to_dict() for i in range(len(metadata))]
747
+ self.pose_cache = {}
748
+ self.overlap_cache = {}
749
+ self.invalid_scenes = set()
750
+ self.invalid_metadata_indices = set()
751
+ self.overlap_labels_root = self._resolve_overlap_labels_root(base_path, self.overlap_labels_root)
752
+ self._validate_condition_config()
753
+
754
+ total_items = len(self.data)
755
+ total_scenes = len(self.scene_names)
756
+ total_captions = sum(len(v) for v in self.scene_captions.values())
757
+ metadata_msg = f", metadata_rows={len(self.metadata_rows)}" if self.metadata_rows else ""
758
+ effective_len = (len(self.metadata_rows) if self.metadata_rows else total_scenes) * repeat
759
+ print(f"CamVideoDataset: {total_scenes} scenes, {total_captions} captions{metadata_msg}, "
760
+ f"repeat={repeat}, cam_position_scale={self.cam_position_scale}, "
761
+ f"effective length={total_items}")
762
+
763
+ def _resolve_overlap_labels_root(self, base_path, overlap_labels_root):
764
+ candidate_roots = []
765
+ if overlap_labels_root is not None:
766
+ candidate_roots.append(overlap_labels_root)
767
+ if base_path is not None:
768
+ candidate_roots.append(os.path.join(base_path, "overlap_labels"))
769
+ for root in candidate_roots:
770
+ if root is not None and os.path.isdir(root):
771
+ return root
772
+ return overlap_labels_root
773
+
774
+ def _validate_condition_config(self):
775
+ if self.condition_t2v_ratio < 0 or self.condition_i2v_ratio < 0:
776
+ raise ValueError("Condition sampling ratios must be non-negative.")
777
+ if self.condition_t2v_ratio + self.condition_i2v_ratio >= 1.0:
778
+ raise ValueError("condition_t2v_ratio + condition_i2v_ratio must be < 1.0.")
779
+ needs_overlap = (
780
+ self.use_condition_context_frames
781
+ and self.condition_frame_mode == "first_plus_overlap"
782
+ and self.condition_history_keyframes
783
+ and self.num_condition_frames > 1
784
+ )
785
+ if needs_overlap and (self.overlap_labels_root is None or not os.path.isdir(self.overlap_labels_root)):
786
+ raise FileNotFoundError(
787
+ "K-frame condition mode requires overlap_labels_root. "
788
+ "Pass --overlap_labels_root or keep overlap_labels under dataset_base_path/overlap_labels."
789
+ )
790
+
791
+ def _load_scene_poses(self, scene_name):
792
+ if scene_name not in self.pose_cache:
793
+ json_path = os.path.join(self.jsons_dir, scene_name + ".json")
794
+ try:
795
+ with open(json_path, "r") as f:
796
+ data = json.load(f)
797
+ except (FileNotFoundError, json.JSONDecodeError) as e:
798
+ raise ValueError(f"Pose JSON for scene '{scene_name}' is missing or corrupt: {e}")
799
+ if not isinstance(data, dict) or "CineCameraActor" not in data:
800
+ raise ValueError(
801
+ f"Pose JSON for scene '{scene_name}' lacks 'CineCameraActor' key "
802
+ f"(found keys: {list(data.keys()) if isinstance(data, dict) else type(data).__name__})."
803
+ )
804
+ cine = data["CineCameraActor"]
805
+ if not isinstance(cine, dict) or len(cine) == 0:
806
+ raise ValueError(f"Pose JSON for scene '{scene_name}' has empty 'CineCameraActor' entries.")
807
+ self.pose_cache[scene_name] = cine
808
+ return self.pose_cache[scene_name]
809
+
810
+ def _find_nearest_caption(self, scene_name, start_frame):
811
+ captions = self.scene_captions[scene_name]
812
+ best_idx = 0
813
+ best_dist = abs(captions[0][0] - start_frame)
814
+ for i, (clip_start, _) in enumerate(captions):
815
+ dist = abs(clip_start - start_frame)
816
+ if dist < best_dist:
817
+ best_dist = dist
818
+ best_idx = i
819
+ return captions[best_idx][1]
820
+
821
+ @staticmethod
822
+ def _compute_rt(position, rotation):
823
+ x, y, z = position
824
+ yaw_rad = np.radians(rotation[2])
825
+ cos_y, sin_y = np.cos(yaw_rad), np.sin(yaw_rad)
826
+ R = np.array([[cos_y, -sin_y, 0], [sin_y, cos_y, 0], [0, 0, 1]])
827
+ return [x, y, z] + R.flatten().tolist()
828
+
829
+ @staticmethod
830
+ def _to_relative_rt(rt_list, ref_rt):
831
+ R_ref = np.array(ref_rt[3:]).reshape(3, 3)
832
+ T_ref = np.array(ref_rt[:3]).reshape(3, 1)
833
+ R_ref_inv = R_ref.T
834
+ T_ref_inv = -R_ref_inv @ T_ref
835
+ result = []
836
+ for rt in rt_list:
837
+ R_i = np.array(rt[3:]).reshape(3, 3)
838
+ T_i = np.array(rt[:3]).reshape(3, 1)
839
+ R_new = R_ref_inv @ R_i
840
+ T_new = R_ref_inv @ T_i + T_ref_inv
841
+ result.append(T_new.flatten().tolist() + R_new.flatten().tolist())
842
+ return result
843
+
844
+ def crop_and_resize(self, image, target_height, target_width):
845
+ width, height = image.size
846
+ scale = max(target_width / width, target_height / height)
847
+ image = torchvision.transforms.functional.resize(
848
+ image,
849
+ (round(height * scale), round(width * scale)),
850
+ interpolation=torchvision.transforms.InterpolationMode.BILINEAR
851
+ )
852
+ image = torchvision.transforms.functional.center_crop(image, (target_height, target_width))
853
+ return image
854
+
855
+ def get_height_width(self, image):
856
+ if self.dynamic_resolution:
857
+ width, height = image.size
858
+ if width * height > self.max_pixels:
859
+ scale = (width * height / self.max_pixels) ** 0.5
860
+ height, width = int(height / scale), int(width / scale)
861
+ height = height // self.height_division_factor * self.height_division_factor
862
+ width = width // self.width_division_factor * self.width_division_factor
863
+ else:
864
+ height, width = self.height, self.width
865
+ return height, width
866
+
867
+ def _load_resized_frame(self, scene_name, frame_index, target_height, target_width):
868
+ frame_path = os.path.join(self.frames_dir, scene_name, f"{frame_index:04d}.png")
869
+ img = Image.open(frame_path).convert("RGB")
870
+ return self.crop_and_resize(img, target_height, target_width)
871
+
872
+ @staticmethod
873
+ def _parse_frame_token(token):
874
+ token = str(token).strip()
875
+ if not token:
876
+ return None, None
877
+ parts = token.split("/")
878
+ if parts and parts[0] == "frames":
879
+ parts = parts[1:]
880
+ if len(parts) < 2:
881
+ return None, None
882
+ scene_name = "/".join(parts[:-1])
883
+ stem = os.path.splitext(parts[-1])[0]
884
+ try:
885
+ frame_index = int(stem)
886
+ except ValueError:
887
+ return None, None
888
+ return scene_name, frame_index
889
+
890
+ def _metadata_scene_and_indices(self, row):
891
+ video_field = str(row.get("video", "") or "")
892
+ tokens = [t for t in video_field.split("|") if t]
893
+ parsed = [self._parse_frame_token(t) for t in tokens]
894
+ parsed = [(s, i) for s, i in parsed if s is not None and i is not None]
895
+ if parsed:
896
+ scene_name = str(row.get("video_name", "") or parsed[0][0])
897
+ frame_indices = [i for _, i in parsed[: self.num_frames]]
898
+ else:
899
+ scene_name = str(row.get("video_name", "") or "").strip()
900
+ if not scene_name:
901
+ raise ValueError("metadata row lacks video_name and parseable video paths")
902
+ start_frame = int(row.get("start_frame", 0) or 0)
903
+ frame_indices = list(range(start_frame, start_frame + self.num_frames))
904
+ if len(frame_indices) < self.num_frames:
905
+ raise ValueError(f"metadata row has {len(frame_indices)} frames, expected {self.num_frames}")
906
+ return scene_name, frame_indices[: self.num_frames]
907
+
908
+ def _resolve_geometry_path(self, token):
909
+ token = str(token).strip()
910
+ if not token:
911
+ return None
912
+ if os.path.isabs(token):
913
+ return token
914
+ root = self.geometry_memory_root or self.base_path
915
+ return os.path.join(root, token)
916
+
917
+ def _load_geometry_memory_frames(self, row):
918
+ value = row.get(self.geometry_memory_column, None)
919
+ if value is None or str(value).strip() == "" or str(value).lower() == "nan":
920
+ if self.use_geometry_spatial_memory:
921
+ raise ValueError(
922
+ f"metadata row lacks required geometry column '{self.geometry_memory_column}'"
923
+ )
924
+ return []
925
+ tokens = [token for token in str(value).split("|") if token.strip()]
926
+ if not tokens:
927
+ raise ValueError(f"empty geometry memory field '{self.geometry_memory_column}'")
928
+
929
+ if len(tokens) == 1:
930
+ path = self._resolve_geometry_path(tokens[0])
931
+ if path is None:
932
+ return []
933
+ if os.path.isdir(path):
934
+ names = sorted(
935
+ name
936
+ for name in os.listdir(path)
937
+ if os.path.splitext(name)[1].lower() in (".png", ".jpg", ".jpeg", ".webp")
938
+ )
939
+ paths = [os.path.join(path, name) for name in names]
940
+ return [Image.open(frame_path).convert("RGB") for frame_path in paths]
941
+ if os.path.splitext(path)[1].lower() in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
942
+ reader = imageio.get_reader(path)
943
+ try:
944
+ frames = [Image.fromarray(frame).convert("RGB") for frame in reader]
945
+ finally:
946
+ reader.close()
947
+ if not frames:
948
+ raise ValueError(f"geometry memory video has no frames: {path}")
949
+ return frames
950
+
951
+ frames = []
952
+ for token in tokens:
953
+ path = self._resolve_geometry_path(token)
954
+ if path is None or not os.path.isfile(path):
955
+ raise FileNotFoundError(f"geometry memory frame not found: {path}")
956
+ frames.append(Image.open(path).convert("RGB"))
957
+ return frames
958
+
959
+ def _load_overlap_frames(self, scene_name, frame_index):
960
+ if self.overlap_labels_root is None:
961
+ return []
962
+ cache_key = (scene_name, int(frame_index))
963
+ if cache_key not in self.overlap_cache:
964
+ overlap_path = os.path.join(self.overlap_labels_root, scene_name, f"{int(frame_index)}.json")
965
+ if not os.path.exists(overlap_path):
966
+ self.overlap_cache[cache_key] = []
967
+ else:
968
+ with open(overlap_path, "r") as f:
969
+ overlap_data = json.load(f)
970
+ overlaps = overlap_data.get("overlapping_frames", [])
971
+ self.overlap_cache[cache_key] = [int(idx) for idx in overlaps]
972
+ return self.overlap_cache[cache_key]
973
+
974
+ def _compute_scene_rt(self, scene_name, frame_index):
975
+ frame_data = self._load_scene_poses(scene_name)[str(int(frame_index))]
976
+ raw_pos = frame_data["position"]
977
+ pos = [float(p) * self.cam_position_scale for p in raw_pos]
978
+ return self._compute_rt(pos, frame_data["rotation"])
979
+
980
+ def _sample_condition_mode(self):
981
+ if not self.use_condition_context_frames:
982
+ return "disabled"
983
+ if (
984
+ self.condition_frame_mode != "first_plus_overlap"
985
+ or not self.condition_history_keyframes
986
+ or self.num_condition_frames <= 1
987
+ ):
988
+ return "first_frame_only"
989
+ sample = random.random()
990
+ if sample < self.condition_t2v_ratio:
991
+ return "text_only"
992
+ if sample < self.condition_t2v_ratio + self.condition_i2v_ratio:
993
+ return "first_frame_only"
994
+ return "first_plus_overlap"
995
+
996
+ def _sample_overlap_conditions(self, scene_name, start_frame, ref_rt, target_height, target_width, num_extra_conditions):
997
+ if num_extra_conditions <= 0:
998
+ return [], [], []
999
+ window_indices = set(range(start_frame, start_frame + self.num_frames))
1000
+ target_candidates = list(range(start_frame + 1, start_frame + self.num_frames))
1001
+ sampled_target_frames = random.sample(target_candidates, k=min(num_extra_conditions, len(target_candidates)))
1002
+ overlap_frames = []
1003
+ overlap_indices = []
1004
+ overlap_actions = []
1005
+ used_condition_indices = set()
1006
+ for target_frame_idx in sampled_target_frames:
1007
+ candidate_indices = [
1008
+ idx for idx in self._load_overlap_frames(scene_name, target_frame_idx)
1009
+ if idx not in window_indices and idx != target_frame_idx and idx not in used_condition_indices
1010
+ ]
1011
+ if len(candidate_indices) == 0:
1012
+ return None
1013
+ chosen_idx = random.choice(candidate_indices)
1014
+ used_condition_indices.add(chosen_idx)
1015
+ overlap_indices.append(chosen_idx)
1016
+ overlap_frames.append(self._load_resized_frame(scene_name, chosen_idx, target_height, target_width))
1017
+ if self.condition_use_camera_pose:
1018
+ overlap_rt = self._compute_scene_rt(scene_name, chosen_idx)
1019
+ overlap_actions.append(self._to_relative_rt([overlap_rt], ref_rt)[0])
1020
+ if len(overlap_frames) != num_extra_conditions:
1021
+ return None
1022
+ return overlap_frames, overlap_indices, overlap_actions
1023
+
1024
+ def _try_get_sample(self, data_item):
1025
+ scene_name = data_item["video_name"]
1026
+ cam_data = self._load_scene_poses(scene_name)
1027
+ max_start = len(cam_data) - self.num_frames
1028
+ if max_start < 0:
1029
+ raise ValueError(f"Scene {scene_name} has fewer than {self.num_frames} frames.")
1030
+ start_frame = data_item["start_frame"]
1031
+ end_frame = start_frame + self.num_frames - 1
1032
+ assert end_frame == data_item["end_frame"]
1033
+
1034
+ frames = []
1035
+ for i in range(start_frame, end_frame + 1):
1036
+ frame_path = os.path.join(self.frames_dir, scene_name, f"{i:04d}.png")
1037
+ img = Image.open(frame_path).convert("RGB")
1038
+ img = self.crop_and_resize(img, *self.get_height_width(img))
1039
+ frames.append(img)
1040
+
1041
+ # prompt = self._find_nearest_caption(scene_name, start_frame)
1042
+ prompt = data_item["prompt"]
1043
+
1044
+ rt_list_abs = []
1045
+ for i in range(start_frame, end_frame + 1):
1046
+ key = str(i)
1047
+ if key not in cam_data:
1048
+ raise ValueError(f"Scene {scene_name} missing pose for frame {i}.")
1049
+ frame_data = cam_data[key]
1050
+ raw_pos = frame_data["position"]
1051
+ pos = [float(p) * self.cam_position_scale for p in raw_pos]
1052
+ rt = self._compute_rt(pos, frame_data["rotation"])
1053
+ rt_list_abs.append(rt)
1054
+
1055
+ rt_list = self._to_relative_rt(rt_list_abs, rt_list_abs[0])
1056
+ pose_indices = list(range(0, self.num_frames, 4))
1057
+ actions = [rt_list[i] for i in pose_indices]
1058
+
1059
+ return {
1060
+ "video": frames,
1061
+ "prompt": prompt,
1062
+ "actions": actions,
1063
+ "video_name": scene_name,
1064
+ "start_frame": start_frame,
1065
+ "end_frame": end_frame,
1066
+ **self._build_condition_context_payload(
1067
+ frames=frames,
1068
+ scene_name=scene_name,
1069
+ start_frame=start_frame,
1070
+ ref_rt=rt_list_abs[0],
1071
+ actions=actions,
1072
+ ),
1073
+ }
1074
+
1075
+ def _try_get_metadata_sample(self, row):
1076
+ scene_name, frame_indices = self._metadata_scene_and_indices(row)
1077
+ start_frame = int(frame_indices[0])
1078
+ end_frame = int(frame_indices[-1])
1079
+ cam_data = self._load_scene_poses(scene_name)
1080
+
1081
+ frames = []
1082
+ for frame_idx in frame_indices:
1083
+ frame_path = os.path.join(self.frames_dir, scene_name, f"{int(frame_idx):04d}.png")
1084
+ img = Image.open(frame_path).convert("RGB")
1085
+ img = self.crop_and_resize(img, *self.get_height_width(img))
1086
+ frames.append(img)
1087
+
1088
+ prompt = row.get("prompt", None)
1089
+ if prompt is None or str(prompt).strip() == "" or str(prompt).lower() == "nan":
1090
+ prompt = self._find_nearest_caption(scene_name, start_frame)
1091
+ else:
1092
+ prompt = str(prompt)
1093
+
1094
+ rt_list_abs = []
1095
+ for frame_idx in frame_indices:
1096
+ key = str(int(frame_idx))
1097
+ if key not in cam_data:
1098
+ raise ValueError(f"Scene {scene_name} missing pose for frame {frame_idx}.")
1099
+ frame_data = cam_data[key]
1100
+ raw_pos = frame_data["position"]
1101
+ pos = [float(p) * self.cam_position_scale for p in raw_pos]
1102
+ rt = self._compute_rt(pos, frame_data["rotation"])
1103
+ rt_list_abs.append(rt)
1104
+
1105
+ rt_list = self._to_relative_rt(rt_list_abs, rt_list_abs[0])
1106
+ pose_indices = list(range(0, len(frame_indices), 4))
1107
+ actions = [rt_list[i] for i in pose_indices]
1108
+ geometry_memory_frames = self._load_geometry_memory_frames(row)
1109
+
1110
+ return {
1111
+ "video": frames,
1112
+ "prompt": prompt,
1113
+ "actions": actions,
1114
+ "video_name": scene_name,
1115
+ "start_frame": start_frame,
1116
+ "end_frame": end_frame,
1117
+ "geometry_memory_frames": geometry_memory_frames,
1118
+ **self._build_condition_context_payload(
1119
+ frames=frames,
1120
+ scene_name=scene_name,
1121
+ start_frame=start_frame,
1122
+ ref_rt=rt_list_abs[0],
1123
+ actions=actions,
1124
+ ),
1125
+ }
1126
+
1127
+ def __getitem__(self, data_id):
1128
+ n = len(self.data)
1129
+ if n == 0:
1130
+ raise RuntimeError("CamVideoDataset has no scenes.")
1131
+ max_attempts = min(64, n)
1132
+ last_error = None
1133
+ for attempt in range(max_attempts):
1134
+ idx = (data_id + attempt) % n
1135
+ data_item = self.data[idx]
1136
+ # if scene_name in self.invalid_scenes:
1137
+ # continue
1138
+ # try:
1139
+ return self._try_get_sample(data_item)
1140
+ # except (ValueError, FileNotFoundError, KeyError, OSError) as e:
1141
+ # self.invalid_scenes.add(scene_name)
1142
+ # last_error = e
1143
+ # if attempt < 3 or attempt % 8 == 0:
1144
+ # print(
1145
+ # f"[CamVideoDataset] Skipping invalid scene '{scene_name}' "
1146
+ # f"({type(e).__name__}: {e}); attempt {attempt + 1}/{max_attempts}"
1147
+ # )
1148
+ # continue
1149
+ raise RuntimeError(
1150
+ f"CamVideoDataset: exhausted {max_attempts} attempts starting from index {data_id}; "
1151
+ f"last error: {type(last_error).__name__}: {last_error}"
1152
+ )
1153
+
1154
+ def _build_condition_context_payload(self, frames, scene_name, start_frame, ref_rt, actions):
1155
+ if not self.use_condition_context_frames:
1156
+ return {}
1157
+ payload = {
1158
+ "use_condition_context_frames": False,
1159
+ "condition_frames": [],
1160
+ "condition_frame_indices": [],
1161
+ "condition_source": None,
1162
+ "condition_actions": [],
1163
+ }
1164
+ condition_mode = self._sample_condition_mode()
1165
+ payload["condition_source"] = condition_mode
1166
+ if condition_mode == "text_only":
1167
+ return payload
1168
+ payload["use_condition_context_frames"] = True
1169
+ if self.condition_first_frame:
1170
+ payload["condition_frames"].append(frames[0])
1171
+ payload["condition_frame_indices"].append(start_frame)
1172
+ payload["condition_source"] = "first_frame_only"
1173
+ if self.condition_use_camera_pose and actions:
1174
+ payload["condition_actions"].append(list(actions[0]))
1175
+ if (
1176
+ condition_mode == "first_plus_overlap"
1177
+ and self.condition_history_keyframes
1178
+ and self.num_condition_frames > len(payload["condition_frames"])
1179
+ ):
1180
+ num_extra_conditions = self.num_condition_frames - len(payload["condition_frames"])
1181
+ overlap_payload = self._sample_overlap_conditions(
1182
+ scene_name=scene_name,
1183
+ start_frame=start_frame,
1184
+ ref_rt=ref_rt,
1185
+ target_height=frames[0].size[1],
1186
+ target_width=frames[0].size[0],
1187
+ num_extra_conditions=num_extra_conditions,
1188
+ )
1189
+ if overlap_payload is None:
1190
+ return payload
1191
+ overlap_frames, overlap_indices, overlap_actions = overlap_payload
1192
+ payload["condition_frames"].extend(overlap_frames)
1193
+ payload["condition_frame_indices"].extend(overlap_indices)
1194
+ if self.condition_use_camera_pose:
1195
+ payload["condition_actions"].extend(overlap_actions)
1196
+ payload["condition_source"] = "first_plus_overlap"
1197
+ return payload
1198
+
1199
+ def __len__(self):
1200
+ return len(self.data)
1201
+
1202
+
1203
+ class DiffusionTrainingModule(torch.nn.Module):
1204
+ def __init__(self):
1205
+ super().__init__()
1206
+
1207
+
1208
+ def to(self, *args, **kwargs):
1209
+ for name, model in self.named_children():
1210
+ model.to(*args, **kwargs)
1211
+ return self
1212
+
1213
+
1214
+ def trainable_modules(self):
1215
+ trainable_modules = filter(lambda p: p.requires_grad, self.parameters())
1216
+ return trainable_modules
1217
+
1218
+
1219
+ def trainable_param_names(self):
1220
+ trainable_param_names = list(filter(lambda named_param: named_param[1].requires_grad, self.named_parameters()))
1221
+ trainable_param_names = set([named_param[0] for named_param in trainable_param_names])
1222
+ return trainable_param_names
1223
+
1224
+
1225
+ def add_lora_to_model(self, model, target_modules, lora_rank, lora_alpha=None):
1226
+ if lora_alpha is None:
1227
+ lora_alpha = lora_rank
1228
+ lora_config = LoraConfig(r=lora_rank, lora_alpha=lora_alpha, target_modules=target_modules)
1229
+ model = inject_adapter_in_model(lora_config, model)
1230
+ return model
1231
+
1232
+
1233
+ def export_trainable_state_dict(self, state_dict, remove_prefix=None):
1234
+ trainable_param_names = self.trainable_param_names()
1235
+ state_dict = {name: param for name, param in state_dict.items() if name in trainable_param_names}
1236
+ if remove_prefix is not None:
1237
+ state_dict_ = {}
1238
+ for name, param in state_dict.items():
1239
+ if name.startswith(remove_prefix):
1240
+ name = name[len(remove_prefix):]
1241
+ state_dict_[name] = param
1242
+ state_dict = state_dict_
1243
+ return state_dict
1244
+
1245
+
1246
+
1247
+ class ModelLogger:
1248
+ def __init__(self, output_path, remove_prefix_in_ckpt=None, state_dict_converter=lambda x:x):
1249
+ self.output_path = output_path
1250
+ self.remove_prefix_in_ckpt = remove_prefix_in_ckpt
1251
+ self.state_dict_converter = state_dict_converter
1252
+
1253
+
1254
+ def on_step_end(self, loss):
1255
+ pass
1256
+
1257
+
1258
+ def on_epoch_end(self, accelerator, model, epoch_id):
1259
+ accelerator.wait_for_everyone()
1260
+ if accelerator.is_main_process:
1261
+ state_dict = accelerator.get_state_dict(model)
1262
+ state_dict = accelerator.unwrap_model(model).export_trainable_state_dict(state_dict, remove_prefix=self.remove_prefix_in_ckpt)
1263
+ state_dict = self.state_dict_converter(state_dict)
1264
+ os.makedirs(self.output_path, exist_ok=True)
1265
+ path = os.path.join(self.output_path, f"epoch-{epoch_id}.safetensors")
1266
+ accelerator.save(state_dict, path, safe_serialization=True)
1267
+
1268
+
1269
+
1270
+ def launch_training_task(
1271
+ dataset: torch.utils.data.Dataset,
1272
+ model: DiffusionTrainingModule,
1273
+ model_logger: ModelLogger,
1274
+ optimizer: torch.optim.Optimizer,
1275
+ scheduler: torch.optim.lr_scheduler.LRScheduler,
1276
+ num_epochs: int = 1,
1277
+ gradient_accumulation_steps: int = 1,
1278
+ ):
1279
+ dataloader = torch.utils.data.DataLoader(dataset, shuffle=True, collate_fn=lambda x: x[0], drop_last=True)
1280
+ accelerator = Accelerator(gradient_accumulation_steps=gradient_accumulation_steps)
1281
+ model, optimizer, dataloader, scheduler = accelerator.prepare(model, optimizer, dataloader, scheduler)
1282
+
1283
+ for epoch_id in range(num_epochs):
1284
+ for data in tqdm(dataloader):
1285
+ with accelerator.accumulate(model):
1286
+ optimizer.zero_grad()
1287
+ loss = model(data)
1288
+ accelerator.backward(loss)
1289
+ optimizer.step()
1290
+ model_logger.on_step_end(loss)
1291
+ scheduler.step()
1292
+ model_logger.on_epoch_end(accelerator, model, epoch_id)
1293
+
1294
+ def launch_data_process_task(model: DiffusionTrainingModule, dataset, output_path="./models"):
1295
+ dataloader = torch.utils.data.DataLoader(dataset, shuffle=False, collate_fn=lambda x: x[0], drop_last=True)
1296
+ accelerator = Accelerator()
1297
+ model, dataloader = accelerator.prepare(model, dataloader)
1298
+ os.makedirs(os.path.join(output_path, "data_cache"), exist_ok=True)
1299
+ for data_id, data in enumerate(tqdm(dataloader)):
1300
+ with torch.no_grad():
1301
+ inputs = model.forward_preprocess(data)
1302
+ inputs = {key: inputs[key] for key in model.model_input_keys if key in inputs}
1303
+ torch.save(inputs, os.path.join(output_path, "data_cache", f"{data_id}.pth"))
1304
+
1305
+
1306
+
1307
+ def wan_parser():
1308
+ parser = argparse.ArgumentParser(description="Simple example of a training script.")
1309
+ parser.add_argument("--dataset_base_path", type=str, default="", required=True, help="Base path of the dataset.")
1310
+ parser.add_argument("--dataset_metadata_path", type=str, default=None, help="Path to the metadata file of the dataset.")
1311
+ parser.add_argument("--max_pixels", type=int, default=1280*720, help="Maximum number of pixels per frame, used for dynamic resolution..")
1312
+ parser.add_argument("--height", type=int, default=None, help="Height of images or videos. Leave `height` and `width` empty to enable dynamic resolution.")
1313
+ parser.add_argument("--width", type=int, default=None, help="Width of images or videos. Leave `height` and `width` empty to enable dynamic resolution.")
1314
+ parser.add_argument("--num_frames", type=int, default=81, help="Number of frames per video. Frames are sampled from the video prefix.")
1315
+ parser.add_argument("--data_file_keys", type=str, default="image,video", help="Data file keys in the metadata. Comma-separated.")
1316
+ parser.add_argument("--dataset_repeat", type=int, default=1, help="Number of times to repeat the dataset per epoch.")
1317
+ parser.add_argument("--model_paths", type=str, default=None, help="Paths to load models. In JSON format.")
1318
+ parser.add_argument("--model_id_with_origin_paths", type=str, default=None, help="Model ID with origin paths, e.g., Wan-AI/Wan2.1-T2V-1.3B:diffusion_pytorch_model*.safetensors. Comma-separated.")
1319
+ parser.add_argument("--learning_rate", type=float, default=1e-4, help="Learning rate.")
1320
+ parser.add_argument("--num_epochs", type=int, default=1, help="Number of epochs.")
1321
+ parser.add_argument("--output_path", type=str, default="./models", help="Output save path.")
1322
+ parser.add_argument("--remove_prefix_in_ckpt", type=str, default="pipe.dit.", help="Remove prefix in ckpt.")
1323
+ parser.add_argument("--trainable_models", type=str, default=None, help="Models to train, e.g., dit, vae, text_encoder.")
1324
+ parser.add_argument("--lora_base_model", type=str, default=None, help="Which model LoRA is added to.")
1325
+ parser.add_argument("--lora_target_modules", type=str, default="q,k,v,o,ffn.0,ffn.2", help="Which layers LoRA is added to.")
1326
+ parser.add_argument("--lora_rank", type=int, default=32, help="Rank of LoRA.")
1327
+ parser.add_argument("--extra_inputs", default=None, help="Additional model inputs, comma-separated.")
1328
+ parser.add_argument("--use_gradient_checkpointing_offload", default=False, action="store_true", help="Whether to offload gradient checkpointing to CPU memory.")
1329
+ parser.add_argument("--gradient_accumulation_steps", type=int, default=1, help="Gradient accumulation steps.")
1330
+ parser.add_argument("--use_condition_context_frames", default=False, action="store_true", help="Enable appended clean condition latents.")
1331
+ parser.add_argument("--condition_first_frame", default=False, action="store_true", help="Use the current clip first frame as a clean condition frame.")
1332
+ parser.add_argument("--condition_history_keyframes", default=False, action="store_true", help="Use overlap-based keyframes as conditions.")
1333
+ parser.add_argument("--condition_use_camera_pose", default=True, action="store_true", help="Inject camera pose for condition frames.")
1334
+ parser.add_argument("--num_condition_frames", type=int, default=1, help="Number of condition frames.")
1335
+ parser.add_argument("--condition_frame_mode", type=str, default="first_frame_only", help="Condition frame selection mode.")
1336
+ parser.add_argument("--overlap_labels_root", type=str, default=None, help="Root dir for overlap label JSONs.")
1337
+ parser.add_argument("--condition_t2v_ratio", type=float, default=0.10, help="Ratio of text-only condition samples.")
1338
+ parser.add_argument("--condition_i2v_ratio", type=float, default=0.10, help="Ratio of first-frame-only condition samples.")
1339
+ return parser
1340
+
1341
+
1342
+
1343
+ def flux_parser():
1344
+ parser = argparse.ArgumentParser(description="Simple example of a training script.")
1345
+ parser.add_argument("--dataset_base_path", type=str, default="", required=True, help="Base path of the dataset.")
1346
+ parser.add_argument("--dataset_metadata_path", type=str, default=None, help="Path to the metadata file of the dataset.")
1347
+ parser.add_argument("--max_pixels", type=int, default=1024*1024, help="Maximum number of pixels per frame, used for dynamic resolution..")
1348
+ parser.add_argument("--height", type=int, default=None, help="Height of images. Leave `height` and `width` empty to enable dynamic resolution.")
1349
+ parser.add_argument("--width", type=int, default=None, help="Width of images. Leave `height` and `width` empty to enable dynamic resolution.")
1350
+ parser.add_argument("--data_file_keys", type=str, default="image", help="Data file keys in the metadata. Comma-separated.")
1351
+ parser.add_argument("--dataset_repeat", type=int, default=1, help="Number of times to repeat the dataset per epoch.")
1352
+ parser.add_argument("--model_paths", type=str, default=None, help="Paths to load models. In JSON format.")
1353
+ parser.add_argument("--model_id_with_origin_paths", type=str, default=None, help="Model ID with origin paths, e.g., Wan-AI/Wan2.1-T2V-1.3B:diffusion_pytorch_model*.safetensors. Comma-separated.")
1354
+ parser.add_argument("--learning_rate", type=float, default=1e-4, help="Learning rate.")
1355
+ parser.add_argument("--num_epochs", type=int, default=1, help="Number of epochs.")
1356
+ parser.add_argument("--output_path", type=str, default="./models", help="Output save path.")
1357
+ parser.add_argument("--remove_prefix_in_ckpt", type=str, default="pipe.dit.", help="Remove prefix in ckpt.")
1358
+ parser.add_argument("--trainable_models", type=str, default=None, help="Models to train, e.g., dit, vae, text_encoder.")
1359
+ parser.add_argument("--lora_base_model", type=str, default=None, help="Which model LoRA is added to.")
1360
+ parser.add_argument("--lora_target_modules", type=str, default="q,k,v,o,ffn.0,ffn.2", help="Which layers LoRA is added to.")
1361
+ parser.add_argument("--lora_rank", type=int, default=32, help="Rank of LoRA.")
1362
+ parser.add_argument("--extra_inputs", default=None, help="Additional model inputs, comma-separated.")
1363
+ parser.add_argument("--align_to_opensource_format", default=False, action="store_true", help="Whether to align the lora format to opensource format. Only for DiT's LoRA.")
1364
+ parser.add_argument("--use_gradient_checkpointing", default=False, action="store_true", help="Whether to use gradient checkpointing.")
1365
+ parser.add_argument("--use_gradient_checkpointing_offload", default=False, action="store_true", help="Whether to offload gradient checkpointing to CPU memory.")
1366
+ parser.add_argument("--gradient_accumulation_steps", type=int, default=1, help="Gradient accumulation steps.")
1367
+ return parser
code/diffsynth/vram_management/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .layers import *
2
+ from .gradient_checkpointing import *
code/diffsynth/vram_management/gradient_checkpointing.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+
3
+
4
+ def create_custom_forward(module):
5
+ def custom_forward(*inputs, **kwargs):
6
+ return module(*inputs, **kwargs)
7
+ return custom_forward
8
+
9
+
10
+ def gradient_checkpoint_forward(
11
+ model,
12
+ use_gradient_checkpointing,
13
+ use_gradient_checkpointing_offload,
14
+ *args,
15
+ **kwargs,
16
+ ):
17
+ if use_gradient_checkpointing_offload:
18
+ with torch.autograd.graph.save_on_cpu():
19
+ model_output = torch.utils.checkpoint.checkpoint(
20
+ create_custom_forward(model),
21
+ *args,
22
+ **kwargs,
23
+ use_reentrant=False,
24
+ )
25
+ elif use_gradient_checkpointing:
26
+ model_output = torch.utils.checkpoint.checkpoint(
27
+ create_custom_forward(model),
28
+ *args,
29
+ **kwargs,
30
+ use_reentrant=False,
31
+ )
32
+ else:
33
+ model_output = model(*args, **kwargs)
34
+ return model_output
code/diffsynth/vram_management/layers.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch, copy
2
+ from ..models.utils import init_weights_on_device
3
+
4
+
5
+ def cast_to(weight, dtype, device):
6
+ r = torch.empty_like(weight, dtype=dtype, device=device)
7
+ r.copy_(weight)
8
+ return r
9
+
10
+
11
+ class AutoTorchModule(torch.nn.Module):
12
+ def __init__(self):
13
+ super().__init__()
14
+
15
+ def check_free_vram(self):
16
+ _dev = self.computation_device
17
+ if not (isinstance(_dev, torch.device) and _dev.index is not None):
18
+ _dev = 0
19
+ gpu_mem_state = torch.cuda.mem_get_info(_dev)
20
+ used_memory = (gpu_mem_state[1] - gpu_mem_state[0]) / (1024 ** 3)
21
+ return used_memory < self.vram_limit
22
+
23
+ def offload(self):
24
+ if self.state != 0:
25
+ self.to(dtype=self.offload_dtype, device=self.offload_device)
26
+ self.state = 0
27
+
28
+ def onload(self):
29
+ if self.state != 1:
30
+ self.to(dtype=self.onload_dtype, device=self.onload_device)
31
+ self.state = 1
32
+
33
+ def keep(self):
34
+ if self.state != 2:
35
+ self.to(dtype=self.computation_dtype, device=self.computation_device)
36
+ self.state = 2
37
+
38
+
39
+ class AutoWrappedModule(AutoTorchModule):
40
+ def __init__(self, module: torch.nn.Module, offload_dtype, offload_device, onload_dtype, onload_device, computation_dtype, computation_device, vram_limit, **kwargs):
41
+ super().__init__()
42
+ self.module = module.to(dtype=offload_dtype, device=offload_device)
43
+ self.offload_dtype = offload_dtype
44
+ self.offload_device = offload_device
45
+ self.onload_dtype = onload_dtype
46
+ self.onload_device = onload_device
47
+ self.computation_dtype = computation_dtype
48
+ self.computation_device = computation_device
49
+ self.vram_limit = vram_limit
50
+ self.state = 0
51
+
52
+ def forward(self, *args, **kwargs):
53
+ if self.state == 2:
54
+ module = self.module
55
+ else:
56
+ if self.onload_dtype == self.computation_dtype and self.onload_device == self.computation_device:
57
+ module = self.module
58
+ elif self.vram_limit is not None and self.check_free_vram():
59
+ self.keep()
60
+ module = self.module
61
+ else:
62
+ module = copy.deepcopy(self.module).to(dtype=self.computation_dtype, device=self.computation_device)
63
+ return module(*args, **kwargs)
64
+
65
+
66
+ class WanAutoCastLayerNorm(torch.nn.LayerNorm, AutoTorchModule):
67
+ def __init__(self, module: torch.nn.LayerNorm, offload_dtype, offload_device, onload_dtype, onload_device, computation_dtype, computation_device, vram_limit, **kwargs):
68
+ with init_weights_on_device(device=torch.device("meta")):
69
+ super().__init__(module.normalized_shape, eps=module.eps, elementwise_affine=module.elementwise_affine, bias=module.bias is not None, dtype=offload_dtype, device=offload_device)
70
+ self.weight = module.weight
71
+ self.bias = module.bias
72
+ self.offload_dtype = offload_dtype
73
+ self.offload_device = offload_device
74
+ self.onload_dtype = onload_dtype
75
+ self.onload_device = onload_device
76
+ self.computation_dtype = computation_dtype
77
+ self.computation_device = computation_device
78
+ self.vram_limit = vram_limit
79
+ self.state = 0
80
+
81
+ def forward(self, x, *args, **kwargs):
82
+ if self.state == 2:
83
+ weight, bias = self.weight, self.bias
84
+ else:
85
+ if self.onload_dtype == self.computation_dtype and self.onload_device == self.computation_device:
86
+ weight, bias = self.weight, self.bias
87
+ elif self.vram_limit is not None and self.check_free_vram():
88
+ self.keep()
89
+ weight, bias = self.weight, self.bias
90
+ else:
91
+ weight = None if self.weight is None else cast_to(self.weight, self.computation_dtype, self.computation_device)
92
+ bias = None if self.bias is None else cast_to(self.bias, self.computation_dtype, self.computation_device)
93
+ with torch.amp.autocast(device_type=x.device.type):
94
+ x = torch.nn.functional.layer_norm(x.float(), self.normalized_shape, weight, bias, self.eps).type_as(x)
95
+ return x
96
+
97
+
98
+ class AutoWrappedLinear(torch.nn.Linear, AutoTorchModule):
99
+ def __init__(self, module: torch.nn.Linear, offload_dtype, offload_device, onload_dtype, onload_device, computation_dtype, computation_device, vram_limit, name="", **kwargs):
100
+ with init_weights_on_device(device=torch.device("meta")):
101
+ super().__init__(in_features=module.in_features, out_features=module.out_features, bias=module.bias is not None, dtype=offload_dtype, device=offload_device)
102
+ self.weight = module.weight
103
+ self.bias = module.bias
104
+ self.offload_dtype = offload_dtype
105
+ self.offload_device = offload_device
106
+ self.onload_dtype = onload_dtype
107
+ self.onload_device = onload_device
108
+ self.computation_dtype = computation_dtype
109
+ self.computation_device = computation_device
110
+ self.vram_limit = vram_limit
111
+ self.state = 0
112
+ self.name = name
113
+ self.lora_A_weights = []
114
+ self.lora_B_weights = []
115
+ self.lora_merger = None
116
+
117
+ def forward(self, x, *args, **kwargs):
118
+ if self.state == 2:
119
+ weight, bias = self.weight, self.bias
120
+ else:
121
+ if self.onload_dtype == self.computation_dtype and self.onload_device == self.computation_device:
122
+ weight, bias = self.weight, self.bias
123
+ elif self.vram_limit is not None and self.check_free_vram():
124
+ self.keep()
125
+ weight, bias = self.weight, self.bias
126
+ else:
127
+ weight = cast_to(self.weight, self.computation_dtype, self.computation_device)
128
+ bias = None if self.bias is None else cast_to(self.bias, self.computation_dtype, self.computation_device)
129
+ out = torch.nn.functional.linear(x, weight, bias)
130
+
131
+ if len(self.lora_A_weights) == 0:
132
+ # No LoRA
133
+ return out
134
+ elif self.lora_merger is None:
135
+ # Native LoRA inference
136
+ for lora_A, lora_B in zip(self.lora_A_weights, self.lora_B_weights):
137
+ out = out + x @ lora_A.T @ lora_B.T
138
+ else:
139
+ # LoRA fusion
140
+ lora_output = []
141
+ for lora_A, lora_B in zip(self.lora_A_weights, self.lora_B_weights):
142
+ lora_output.append(x @ lora_A.T @ lora_B.T)
143
+ lora_output = torch.stack(lora_output)
144
+ out = self.lora_merger(out, lora_output)
145
+ return out
146
+
147
+
148
+ def enable_vram_management_recursively(model: torch.nn.Module, module_map: dict, module_config: dict, max_num_param=None, overflow_module_config: dict = None, total_num_param=0, vram_limit=None, name_prefix=""):
149
+ for name, module in model.named_children():
150
+ layer_name = name if name_prefix == "" else name_prefix + "." + name
151
+ for source_module, target_module in module_map.items():
152
+ if isinstance(module, source_module):
153
+ num_param = sum(p.numel() for p in module.parameters())
154
+ if max_num_param is not None and total_num_param + num_param > max_num_param:
155
+ module_config_ = overflow_module_config
156
+ else:
157
+ module_config_ = module_config
158
+ module_ = target_module(module, **module_config_, vram_limit=vram_limit, name=layer_name)
159
+ setattr(model, name, module_)
160
+ total_num_param += num_param
161
+ break
162
+ else:
163
+ total_num_param = enable_vram_management_recursively(module, module_map, module_config, max_num_param, overflow_module_config, total_num_param, vram_limit=vram_limit, name_prefix=layer_name)
164
+ return total_num_param
165
+
166
+
167
+ def enable_vram_management(model: torch.nn.Module, module_map: dict, module_config: dict, max_num_param=None, overflow_module_config: dict = None, vram_limit=None):
168
+ enable_vram_management_recursively(model, module_map, module_config, max_num_param, overflow_module_config, total_num_param=0, vram_limit=vram_limit)
169
+ model.vram_management_enabled = True
170
+
code/doc/DEVELOPER.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Developer Guide / 开发者指南
2
+
3
+ Bilingual interactive version: [Project Page → Developer Guide](https://echo-team-joy-future-academy-jd.github.io/Echo-Memory/developer.html)
4
+
5
+ Hands-on development, training/eval workflows, and **Cursor Agent skills** for Echo-Memory.
6
+
7
+ 实战开发、训练评测与 **Cursor Agent 技能**。
8
+
9
+ ---
10
+
11
+ ## Cursor skills / 项目 Skills
12
+
13
+ Project skills live in **`.cursor/skills/`** — reference them in Agent chat (e.g. *use echo-memory-eval to …*).
14
+
15
+ | Skill | English | 中文 |
16
+ | --- | --- | --- |
17
+ | `echo-memory-agent` | Scope prompts, rules, skill index | Prompt 范围、Rules、技能索引 |
18
+ | `echo-memory-train` | Memory baselines & context training | Baseline 与 Context 训练 |
19
+ | `echo-memory-eval` | Replay / revisit & HF checkpoint checks | 回放 / revisit、HF checkpoint check |
20
+ | `echo-memory-release` | gh-pages, i18n, checkpoints doc | gh-pages、i18n、权重文档 |
21
+
22
+ Index: [.cursor/skills/README.md](../.cursor/skills/README.md)
23
+
24
+ ---
25
+
26
+ ## 1. Guide map / 文档地图
27
+
28
+ | | English | 中文 |
29
+ | --- | --- | --- |
30
+ | **README** | Paper overview, quick start, checkpoints, community | 论文概览、快速上手、权重、社区 |
31
+ | **This guide** | Workflows, Cursor skills, Agent tips | 工作流、Skills、Agent 技巧 |
32
+ | **`doc/`** | Dataset & checkpoint reference | 数据集与权重参考 |
33
+
34
+ ---
35
+
36
+ ## 2. Environment & paths / 环境与路径
37
+
38
+ ```bash
39
+ export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
40
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
41
+ export PYTHONPATH=$PWD:${PYTHONPATH:-}
42
+ export OUTPUT_BASE_ROOT=$PWD/outputs
43
+ ```
44
+
45
+ | Pool | English | 中文 |
46
+ | --- | --- | --- |
47
+ | Static in-domain | Default root above — [dataset_preprocessing.md](dataset_preprocessing.md) | 默认路径 — 同上 |
48
+ | Dynamic training | e.g. `data/dynamic-memory-dataset` — [dynamic_dataset_preprocessing.md](dynamic_dataset_preprocessing.md) | 如 `data/dynamic-memory-dataset` — 同上 |
49
+ | Checkpoints | [Echo-Team/Echo-Memory](https://huggingface.co/Echo-Team/Echo-Memory) — [checkpoints.md](checkpoints.md) | 同上 |
50
+
51
+ ---
52
+
53
+ ## 3. Code map / 代码地图
54
+
55
+ | Path | English | 中文 |
56
+ | --- | --- | --- |
57
+ | `.cursor/skills/` | Cursor Agent skills | Agent 技能 |
58
+ | `train/memory_baselines_basic/` | Spatial / SSM / compression | Spatial / SSM / 压缩 |
59
+ | `train/context_learning/` | Context K=1/5/20 | Context 配方 |
60
+ | `eval/v2/` | Replay, revisit | 回放、revisit |
61
+ | `env/memory_baseline_runtime.py` | CKPT → memory profile | 权重 → 记忆配置 |
62
+ | `docs/` | GitHub Pages | 项目页 |
63
+
64
+ ---
65
+
66
+ ## 4. Common workflows / 常用工作流
67
+
68
+ **Train / 训练**
69
+
70
+ ```bash
71
+ bash train/memory_baselines_basic/run_spatial_memory_baseline.sh
72
+ bash train/context_learning/run_pre_qkv_ctx20.sh
73
+ ```
74
+
75
+ **Checkpoint eval / checkpoint 检查**
76
+
77
+ ```bash
78
+ huggingface-cli download Echo-Team/Echo-Memory context_k1/epoch-0.safetensors --local-dir ./ckpts
79
+ export CKPT=./ckpts/context_k1/epoch-0.safetensors
80
+ bash eval/v2/run_static_consistency_loop_and_revisit.sh
81
+ ```
82
+
83
+ Keep the row folder name in `CKPT`.
84
+
85
+ ---
86
+
87
+ ## 5. Agent prompts / 示例 Prompt
88
+
89
+ ```text
90
+ Using echo-memory-eval: download context_k1 from Echo-Team/Echo-Memory
91
+ and run eval/v2/run_basic_replay_gt.sh with the static in-domain pool.
92
+
93
+ Using echo-memory-train: document OUTPUT_BASE_ROOT override in
94
+ run_ablation_block_wise_ssm_two_chunk.sh.
95
+ ```
96
+
97
+ **Public repo hygiene / 公开仓库规范:** no upload bash, internal benchmark names, or machine paths in GitHub.
98
+
99
+ ---
100
+
101
+ ## 6. Site & release / 站点与发布
102
+
103
+ ```bash
104
+ bash scripts/publish_gh_pages.sh
105
+ ```
106
+
107
+ Community QR: [project page → Updates](https://echo-team-joy-future-academy-jd.github.io/Echo-Memory/#updates) or README **Community**.
108
+
109
+ ---
110
+
111
+ ## 7. Checklist / 检查清单
112
+
113
+ - [ ] Quick eval with one HF checkpoint
114
+ - [ ] `doc/checkpoints.md` matches HF folders
115
+ - [ ] Public docs use Echo pool names
116
+ - [ ] Publish gh-pages after site edits; verify EN/中文 toggle
code/doc/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Echo-Memory documentation
2
+
3
+ | Doc | Echo pool | Covers |
4
+ | --- | --- | --- |
5
+ | [DEVELOPER.md](DEVELOPER.md) | **Developer guide** — workflows, `.cursor/skills/`, Cursor Agent |
6
+ | [checkpoints.md](checkpoints.md) | **Hugging Face weights** — [Echo-Team/Echo-Memory](https://huggingface.co/Echo-Team/Echo-Memory) baseline index |
7
+ | [memory_mechanisms.md](memory_mechanisms.md) | **Memory mechanisms** — paper row names, code modules, and training scripts |
8
+ | [dataset_preprocessing.md](dataset_preprocessing.md) | Static in-domain pool | Echo-Team package download → layout → metadata → latents |
9
+ | [dynamic_dataset_preprocessing.md](dynamic_dataset_preprocessing.md) | Dynamic training pool | subset download → export → training settings |
10
+
11
+ **Static in-domain pool:** download the Echo-Team package before in-domain replay/revisit eval.
12
+
13
+ **Dynamic training pool:** SpatialVID subset export + `DATASET_BASE_PATH` before training on the dynamic pool.
code/doc/checkpoints.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Checkpoints (Hugging Face)
2
+
3
+ **Repo:** [Echo-Team/Echo-Memory](https://huggingface.co/Echo-Team/Echo-Memory)
4
+
5
+ Fine-tuned DiT weights on top of [Wan-AI/Wan2.1-T2V-1.3B](https://huggingface.co/Wan-AI/Wan2.1-T2V-1.3B). Released rows are saved as `{row_id}/epoch-0.safetensors` after **1 epoch / 30,000 steps** on the static in-domain pool (640×352, 81-frame chunks). Mechanism names follow [memory_mechanisms.md](memory_mechanisms.md).
6
+
7
+ ## Checkpoint index
8
+
9
+ | Family | Paper row | HF path | Steps | Echo-Memory recipe |
10
+ | --- | --- | --- | ---: | --- |
11
+ | Raw context | Context K=1 | [`context_k1/epoch-0.safetensors`](https://huggingface.co/Echo-Team/Echo-Memory/tree/main/context_k1) | 30,000 | `train/context_learning/run_pre_qkv_ctx1.sh` |
12
+ | Raw context | Context K=20 | TODO | TODO | `train/context_learning/run_pre_qkv_ctx20.sh` |
13
+ | Spatial | Spatial Memory | TODO | TODO | `train/memory_baselines_basic/run_spatial_memory_baseline.sh` |
14
+ | State-space | Block-wise SSM | TODO | TODO | `train/memory_baselines_basic/run_ablation_block_wise_ssm_two_chunk.sh` |
15
+ | State-space | Legacy Hybrid (VideoSSM) | TODO | TODO | `train/memory_baselines_basic/run_videossm_hybrid_baseline.sh` |
16
+ | Spatial | concat text (ablation) | TODO | TODO | `train/memory_baselines_basic/run_ablation_spatial_concat_text_two_chunk.sh` |
17
+ | Spatial | inject none (ablation) | TODO | TODO | `train/memory_baselines_basic/run_ablation_spatial_inject_none_two_chunk.sh` |
18
+ | Spatial | cross-attn t32 (ablation) | TODO | TODO | `train/memory_baselines_basic/run_ablation_spatial_cross_attn_readout_two_chunk.sh` |
19
+ | State-space | SSM ctx1 / every4 / hint21 | TODO | TODO | SSM ablation |
20
+ | State-space | SSM ctx5 / every1 / hint21 | TODO | TODO | SSM ablation |
21
+ | State-space | SSM ctx5 / every4 / hint81 | TODO | TODO | SSM ablation |
22
+
23
+ Context K=5, Context K=20, Spatial memory, FramePack compression, and State-space / SSM rows are TODO and not yet released as `epoch-0` weights.
24
+
25
+ ## Download
26
+
27
+ ```bash
28
+ pip install -U "huggingface_hub[cli]"
29
+
30
+ # one row (keeps HF folder layout under ./ckpts/)
31
+ huggingface-cli download Echo-Team/Echo-Memory context_k1/epoch-0.safetensors --local-dir ./ckpts
32
+
33
+ # all currently released rows
34
+ huggingface-cli download Echo-Team/Echo-Memory --local-dir ./ckpts
35
+ ```
36
+
37
+ Keep the subdirectory name in the local path (e.g. `./ckpts/context_k1/epoch-0.safetensors`). Eval scripts use `env/memory_baseline_runtime.py` to infer memory flags from path substrings; Spatial and SSM checkpoint rows remain TODO.
38
+
39
+ ## Use with Echo-Memory
40
+
41
+ Set the Wan backbone, static in-domain data pool, and checkpoint path:
42
+
43
+ ```bash
44
+ export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
45
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
46
+ export PYTHONPATH=$PWD:${PYTHONPATH:-}
47
+ export CKPT=./ckpts/context_k1/epoch-0.safetensors
48
+ ```
49
+
50
+ **In-domain replay + revisit (paper bundle):**
51
+
52
+ ```bash
53
+ bash eval/v2/run_static_consistency_loop_and_revisit.sh
54
+ bash eval/v2/run_basic_replay_gt.sh
55
+ ```
56
+
57
+ **Open-domain revisit** (first frames already in `assets/opendomain_revisit/`):
58
+
59
+ ```bash
60
+ PHASE=stage1 OOD_DIR=assets/opendomain_revisit \
61
+ bash eval/v2/revisit_suite/run_one_click_revisit_eval.sh
62
+ ```
63
+
64
+ **Visual comparison** (fixed prompt + first frame):
65
+
66
+ ```bash
67
+ python eval/metrics/run_visual_eval.py \
68
+ --ckpt "$CKPT" \
69
+ --output_root ./evals_visual
70
+ ```
71
+
72
+ See [eval/v2/README.md](../eval/v2/README.md) and [eval/metrics/README.md](../eval/metrics/README.md) for full options.
code/doc/dataset_preprocessing.md ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Static in-domain pool — download & preprocessing
2
+
3
+ Echo-Memory’s **static in-domain pool** is released through [Echo-Team/Echo-Memory-Data](https://huggingface.co/datasets/Echo-Team/Echo-Memory-Data) as tar parts under `static_pool_tar_parts/`. The underlying pool is sourced from [KlingTeam/Context-as-Memory-Dataset](https://huggingface.co/datasets/KlingTeam/Context-as-Memory-Dataset) on Hugging Face (Kling Team, SIGGRAPH Asia 2025; [arXiv:2506.03141](https://arxiv.org/abs/2506.03141)). Total size is about **340 GB** — plan disk space before downloading and unpacking.
4
+
5
+ ---
6
+
7
+ ## 1. Download
8
+
9
+ ### Option A — Echo-Team packaged release
10
+
11
+ ```bash
12
+ pip install -U "huggingface_hub[cli]"
13
+
14
+ mkdir -p data
15
+
16
+ huggingface-cli download Echo-Team/Echo-Memory-Data \
17
+ --repo-type dataset \
18
+ --include "static_pool_tar_parts/*" \
19
+ --local-dir ./data/echo-memory-data-release
20
+
21
+ cat ./data/echo-memory-data-release/static_pool_tar_parts/echo-memory-data.tar.part-* | tar -xf - -C ./data
22
+ ```
23
+
24
+ You should end up with `data/Context-as-Memory-Dataset/`.
25
+
26
+ ### Option B — original KlingTeam source
27
+
28
+ If you prefer the upstream release, download or merge the original parts from the [KlingTeam dataset card](https://huggingface.co/datasets/KlingTeam/Context-as-Memory-Dataset):
29
+
30
+ ```bash
31
+ mkdir -p data
32
+ cd data
33
+
34
+ # after all Context-as-Memory-Dataset_* parts are downloaded into this directory:
35
+ cat Context-as-Memory-Dataset_* > Context-as-Memory-Dataset.zip
36
+ unzip Context-as-Memory-Dataset.zip -d .
37
+ ```
38
+
39
+ You should end up with a directory named `Context-as-Memory-Dataset/` (adjust the path below if your folder name differs).
40
+
41
+ ---
42
+
43
+ ## 2. Expected layout (static in-domain pool)
44
+
45
+ After extraction, point `DATASET_BASE_PATH` at the pool root (default: `data/Context-as-Memory-Dataset/`):
46
+
47
+ ```text
48
+ data/Context-as-Memory-Dataset/
49
+ ├── frames/ # 100 scene folders, ~7601 PNGs each
50
+ │ ├── AncientTempleEnv_0/
51
+ │ │ ├── 0000.png
52
+ │ │ └── ...
53
+ │ └── ...
54
+ ├── jsons/ # per-scene camera pose JSON (one file per scene)
55
+ │ ├── AncientTempleEnv_0.json
56
+ │ └── ...
57
+ ├── overlap_labels/ # per-frame overlap indices (used by context retrieval / latent precompute)
58
+ │ ├── AncientTempleEnv_0/
59
+ │ │ ├── 0.json
60
+ │ │ └── ...
61
+ │ └── ...
62
+ ├── captions.txt # segment captions (optional for some workflows)
63
+ └── metadata_full.csv # released Echo-Memory segment metadata
64
+ ```
65
+
66
+ Quick sanity check:
67
+
68
+ ```bash
69
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
70
+
71
+ test -d "${DATASET_BASE_PATH}/frames" && echo "frames OK"
72
+ test -d "${DATASET_BASE_PATH}/jsons" && echo "jsons OK"
73
+ test -d "${DATASET_BASE_PATH}/overlap_labels" && echo "overlap_labels OK"
74
+ ls "${DATASET_BASE_PATH}/frames" | head
75
+ ls "${DATASET_BASE_PATH}/jsons" | head
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 3. Point Echo-Memory at the static in-domain pool
81
+
82
+ ```bash
83
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
84
+ export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
85
+ export PYTHONPATH=$PWD:${PYTHONPATH:-}
86
+ ```
87
+
88
+ Training scripts also accept `data/Context-as-Memory-Dataset` under the repo root if `DATASET_BASE_PATH` is unset.
89
+
90
+ ---
91
+
92
+ ## 4. Metadata (required)
93
+
94
+ `metadata_full.csv` is included in the Echo-Team packaged release. If you downloaded the upstream KlingTeam source instead, fetch the released metadata into the pool root:
95
+
96
+ ```bash
97
+ cd /path/to/Echo-Memory
98
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
99
+
100
+ huggingface-cli download Echo-Team/Echo-Memory-Data metadata_full.csv \
101
+ --repo-type dataset \
102
+ --local-dir "${DATASET_BASE_PATH}"
103
+ ```
104
+
105
+ If you modify the pool or need to rebuild metadata locally, regenerate it from `frames/` and `captions.txt`:
106
+
107
+ ```bash
108
+ bash scripts/run_generate_metadata.sh
109
+ ```
110
+
111
+ You can also generate a smaller custom index for ablations or reduced-size training:
112
+
113
+ ```bash
114
+ OUTPUT_CSV="${DATASET_BASE_PATH}/metadata_1000.csv" \
115
+ METADATA_MAX_ROWS=1000 \
116
+ bash scripts/run_generate_metadata.sh
117
+ ```
118
+
119
+ Pass the custom CSV to training/evaluation with `--dataset_metadata_path "${DATASET_BASE_PATH}/metadata_1000.csv"`.
120
+
121
+ Defaults (override via env vars):
122
+
123
+ | Variable | Default | Meaning |
124
+ | --- | --- | --- |
125
+ | `OUTPUT_CSV` | `${DATASET_BASE_PATH}/metadata_full.csv` | Output metadata path |
126
+ | `SEGMENT_LENGTH` | `81` | Frames per training segment |
127
+ | `CONTEXT_FRAMES` | `5` | Context window used when building metadata |
128
+ | `NUM_WORKERS` | CPU count − 2 | Parallel workers |
129
+ | `METADATA_MAX_ROWS` / `DATASET_SIZE_ROWS` | `0` | Keep only the first N metadata rows after generation; `0` keeps the full CSV |
130
+
131
+ Verify:
132
+
133
+ ```bash
134
+ wc -l "${DATASET_BASE_PATH}/metadata_full.csv"
135
+ head -n 3 "${DATASET_BASE_PATH}/metadata_full.csv"
136
+ ```
137
+
138
+ ---
139
+
140
+ ## 5. Precompute latents (optional, speeds training)
141
+
142
+ If you train with precomputed VAE latents:
143
+
144
+ ```bash
145
+ export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
146
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
147
+ NUM_PROCESSES=8 bash scripts/run_precompute_ctx_target_latents.sh
148
+ ```
149
+
150
+ Latents are written under `${DATASET_BASE_PATH}/latents/`. The script can use `overlap_labels/` when `--use_overlap_labels` is enabled (see `scripts/run_precompute_ctx_target_latents.sh`).
151
+
152
+ ---
153
+
154
+ ## 6. Training pools vs. open-domain assets
155
+
156
+ | Echo pool / asset | Location | Purpose |
157
+ | --- | --- | --- |
158
+ | Static in-domain pool | `DATASET_BASE_PATH` → `data/Context-as-Memory-Dataset` | Training, in-domain replay/revisit, metadata |
159
+ | Dynamic training pool | `DATASET_BASE_PATH` → `data/dynamic-memory-dataset` | Training on the dynamic pool ([guide](dynamic_dataset_preprocessing.md)) |
160
+ | Open-domain first frames | `assets/opendomain_revisit/` | Held-out OOD revisit probes (already in repo) |
161
+
162
+ You do **not** need to rebuild open-domain anchors for the released revisit suite.
163
+
164
+ ---
165
+
166
+ ## 7. Troubleshooting
167
+
168
+ **`DATASET_BASE_PATH is not set`** — export the variable or place data at `data/Context-as-Memory-Dataset` relative to the repo root.
169
+
170
+ **Missing `frames/` or `jsons/`** — re-check unzip path; the root folder name must match what you pass to `DATASET_BASE_PATH`.
171
+
172
+ **Metadata script missing** — ensure you are on the latest Echo-Memory `main` branch; metadata generation is invoked via `scripts/run_generate_metadata.sh`.
173
+
174
+ **Disk space** — keep ~340 GB for raw frames plus extra space for `metadata_full.csv`, `latents/`, and training outputs.
175
+
176
+ ---
177
+
178
+ ## Reference
179
+
180
+ - Static in-domain pool: [dataset_preprocessing.md](dataset_preprocessing.md)
181
+ - Dynamic training pool: [dynamic_dataset_preprocessing.md](dynamic_dataset_preprocessing.md)
code/doc/dynamic_dataset_preprocessing.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dynamic training pool — SpatialVID subset
2
+
3
+ Echo-Memory’s **dynamic training pool** uses a motion-filtered subset of [SpatialVID/SpatialVID](https://huggingface.co/datasets/SpatialVID/SpatialVID): ego-centric clips with camera poses and captions, exported into the same sample format used by the static pool.
4
+
5
+ This guide covers **download → export → training/inference settings** only. Dynamic eval is TODO; current public support is training and inference.
6
+
7
+ **License:** SpatialVID is [CC-BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (non-commercial). Static and dynamic pools may have different licenses — check before mixing runs.
8
+
9
+ ---
10
+
11
+ ## 1. Download (subset)
12
+
13
+ **Hugging Face:** [SpatialVID/SpatialVID](https://huggingface.co/datasets/SpatialVID/SpatialVID)
14
+
15
+ - Accept the dataset terms on Hugging Face before download.
16
+ - Full corpus is large (~7 TB+). For Echo-Memory dynamic training, download **selected groups** only — you do not need the full 545 groups.
17
+
18
+ ```bash
19
+ pip install -U "huggingface_hub[cli]"
20
+ huggingface-cli login
21
+
22
+ export SPATIALVID_ROOT=/path/to/SpatialVID
23
+ hf download SpatialVID/SpatialVID --repo-type dataset --local-dir "${SPATIALVID_ROOT}"
24
+ ```
25
+
26
+ To fetch specific groups, use include patterns or the helper script linked from the [dataset card](https://huggingface.co/datasets/SpatialVID/SpatialVID) (`download_SpatialVID.py` on the SpatialVID GitHub).
27
+
28
+ Extract downloaded `.tar.gz` groups:
29
+
30
+ ```bash
31
+ cd "${SPATIALVID_ROOT}"
32
+ tar -xzvf annotations/group_0001.tar.gz
33
+ tar -xzvf videos/group_0001.tar.gz
34
+ ```
35
+
36
+ ### Raw layout (per clip)
37
+
38
+ ```text
39
+ SPATIALVID_ROOT/
40
+ ├── annotations/group_0001/{clip_id}/
41
+ │ ├── poses.npy # (N, 7) = tx,ty,tz,qx,qy,qz,qw
42
+ │ ├── indexes.txt # pose index → source frame index
43
+ │ ├── caption.json # scene / motion text
44
+ │ └── dyn_masks.npz # optional dynamic-region masks
45
+ ├── videos/group_0001/{clip_id}.mp4
46
+ └── data/train/SpatialVID_metadata.csv
47
+ ```
48
+
49
+ Use `SpatialVID_metadata.csv` to filter clips (e.g. `motion score`, `dynamicRatio`, `sceneType`) when building your subset.
50
+
51
+ ---
52
+
53
+ ## 2. Export to Echo layout (dynamic training pool)
54
+
55
+ Use `data/dynamic-spatialvid-motion60/mixed/` as the public training root and set `DATASET_BASE_PATH` to it:
56
+
57
+ ```text
58
+ data/dynamic-spatialvid-motion60/
59
+ ├── L1/ # single-level exports are also valid roots
60
+ ├── L2/
61
+ ├── L3/
62
+ └── mixed/
63
+ ├── frames/L{1,2,3}/{clip_id}/0000.png ... 0080.png
64
+ ├── jsons/L{1,2,3}/{clip_id}.json
65
+ ├── overlap_labels/L{1,2,3}/{clip_id}/
66
+ ├── captions.txt
67
+ ├── metadata_train.csv
68
+ ├── metadata_train_sample.csv
69
+ ├── metadata_train_sample_L1.csv
70
+ ├── metadata_eval.csv
71
+ └── metadata_eval_2chunk.csv
72
+ ```
73
+
74
+ **Per-clip steps:**
75
+
76
+ | Step | Setting |
77
+ | --- | --- |
78
+ | Frame sample | **81** PNGs per clip, **640×352** |
79
+ | Pose | Interpolate `poses.npy` + `indexes.txt` → `jsons/{clip_id}.json` (Euler `CineCameraActor` format, same as static data) |
80
+ | Prompt | Short caption from `caption.json` (`SceneSummary` or `SceneDescription`) |
81
+ | Overlap | Build `overlap_labels/` for FOV-based context retrieval |
82
+ | Metadata row | `video`, `prompt`, `video_name`, `start_frame`, `end_frame`, optional `level` |
83
+
84
+ `metadata_train.csv` is written at export time. Use `metadata_train_sample.csv` or `metadata_train_sample_L1.csv` for local step checks. Do not re-run `run_generate_metadata.sh` unless you regenerate from raw frames only.
85
+
86
+ ---
87
+
88
+ ## 3. Training settings
89
+
90
+ Same env vars and on-disk layout as the static in-domain pool — only `DATASET_BASE_PATH` changes.
91
+
92
+ ```bash
93
+ export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
94
+ export DATASET_BASE_PATH=data/dynamic-spatialvid-motion60/mixed
95
+ export PYTHONPATH=$PWD:${PYTHONPATH:-}
96
+ ```
97
+
98
+ Recommended settings for the dynamic training pool (match memory baseline scripts):
99
+
100
+ | Parameter | Typical value |
101
+ | --- | --- |
102
+ | Resolution | **640 × 352** |
103
+ | Frames / chunk | **81** |
104
+ | Context frames | **1–20** (recipe-dependent) |
105
+ | `--use_rt_relative` | on |
106
+ | `--enable_fov_retrieval` | on (when `overlap_labels/` present) |
107
+ | `--enable_context_memory` | on for context / spatial / SSM rows |
108
+ | `--timestep_shift` | **15** |
109
+ | Learning rate | **5e-5** (adjust per row) |
110
+
111
+ Example — run a dynamic row:
112
+
113
+ ```bash
114
+ METADATA_NAME=metadata_train.csv bash train/dynamic_spatialvid/run_dyn_spatial_mem.sh
115
+ ```
116
+
117
+ For local one-step validation:
118
+
119
+ ```bash
120
+ METADATA_NAME=metadata_train_sample_L1.csv \
121
+ MAX_TRAIN_STEPS=1 \
122
+ PROGRESS_TOTAL_STEPS=30000 \
123
+ NUM_WORKERS=0 \
124
+ bash train/dynamic_spatialvid/run_dyn_block_wise_ssm.sh
125
+ ```
126
+
127
+ Inference wrappers live under `inference/dynamic_spatialvid/`.
128
+
129
+ ---
130
+
131
+ ## 4. Demo selection
132
+
133
+ Dynamic demos are selected from training-scene replay rather than from fixed eval scripts:
134
+
135
+ 1. Randomly sample candidate scenes from `metadata_train.csv` or `metadata_train_sample.csv`.
136
+ 2. Use the same prompt, first frame, and GT action trajectory for all six dynamic rows.
137
+ 3. Run `inference/unified_inference.py` or `inference/dynamic_spatialvid/*.sh` for each checkpoint.
138
+ 4. Manually pick a representative scene where all rows are viewable.
139
+
140
+ The checked-in README previews are compressed GIFs under `assets/readme_previews/`.
141
+
142
+ ---
143
+
144
+ ## 5. Checklist
145
+
146
+ - [ ] Hugging Face access approved for [SpatialVID/SpatialVID](https://huggingface.co/datasets/SpatialVID/SpatialVID)
147
+ - [ ] Subset of `group_****` archives downloaded and extracted
148
+ - [ ] Clips filtered (poses + caption present; optional motion / dynamic filters)
149
+ - [ ] `frames/`, `jsons/`, `metadata_train.csv` under one root
150
+ - [ ] (Recommended) `overlap_labels/` for FOV retrieval
151
+ - [ ] `DATASET_BASE_PATH` exported before training/inference
152
+
153
+ ---
154
+
155
+ ## Reference
156
+
157
+ - SpatialVID: [SpatialVID/SpatialVID](https://huggingface.co/datasets/SpatialVID/SpatialVID) · [arXiv:2509.09676](https://arxiv.org/abs/2509.09676)
158
+ - Static in-domain pool: [dataset_preprocessing.md](dataset_preprocessing.md)
159
+ - Dynamic training pool: [dynamic_dataset_preprocessing.md](dynamic_dataset_preprocessing.md)
code/doc/memory_mechanisms.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Memory Mechanisms
2
+
3
+ This note maps the paper's memory rows to the repository implementation and explains the modeling role of each family. Echo-Memory treats memory as a controlled intervention on what information from chunk 1 is stored and how chunk 2 reads it back during denoising.
4
+
5
+ ## Modeling View
6
+
7
+ All rows use the same action-conditioned Wan DiT backbone and the same two-chunk training/evaluation setup:
8
+
9
+ 1. **Context chunk:** clean history frames are encoded into latent/context tokens, optionally with matched camera RT actions.
10
+ 2. **Target chunk:** noisy target latents are denoised while the selected memory mechanism exposes information from the context chunk.
11
+ 3. **Read-out:** memory is injected through raw context concatenation, compressed context tokens, spatial memory tokens, or recurrent state-space modules attached to DiT blocks.
12
+
13
+ The ablations are designed to change only the memory pathway while keeping the backbone, action conditioning, resolution, chunk length, and training schedule aligned.
14
+
15
+ ## Paper Rows
16
+
17
+ | Paper family | Paper row / repo name | What is stored or read | Main code path | Training entry |
18
+ | --- | --- | --- | --- | --- |
19
+ | Raw context | `context_k1`, `context_k5`, `context_k20` | Uncompressed retrieved context frames. `K=1` is the anchor/I2V floor; `K=5/20` are context-learning capacity rows. | `diffsynth/pipelines/wan_video_new.py` context latent path | `train/context_learning/run_pre_qkv_ctx{1,5,20}.sh` |
20
+ | Compression | `framepack_weight` | Context tokens are kept at the same length but temporally reweighted. | `diffsynth/models/memory/framepack_weight.py` | `train/memory_baselines_basic/run_ablation_framepack_weight_two_chunk.sh` |
21
+ | Compression | `framepack_len_r2`, `framepack_len_r4` | Context latents and matched RT actions are pooled along time. | `diffsynth/models/memory/framepack_length.py` | `train/memory_baselines_basic/run_ablation_framepack_len_r{2,4}_two_chunk.sh` |
22
+ | Compression | `framepack_hybrid_r2`, `framepack_hybrid_r4` | Length compression plus token reweighting. | `wan_video_new.py` + FramePack helpers | `train/memory_baselines_basic/run_ablation_framepack_hybrid_r*_weight_two_chunk.sh` |
23
+ | Token-grid | `spatial_mem` | Context tokens are time-averaged and summarized into learned grid tokens. This is the implementation behind the currently reported `spatial_mem` row; it does **not** reconstruct depth or 3D geometry. | `diffsynth/models/memory/spatial_grid_memory.py` | `train/memory_baselines_basic/run_spatial_memory_baseline.sh` |
24
+ | Token-grid | `spatial_inject_none`, `spatial_concat_text`, `spatial_cross_attn_readout` | Same token-grid storage, different read-out: withheld, text-KV concat, or dedicated cross-attention. | `spatial_grid_memory.py` read-out helpers | matching `run_ablation_spatial_*_two_chunk.sh` scripts |
25
+ | Geometry-grounded spatial | `geometry_spatial_mem` | A static scene is reconstructed outside the DiT using depth, intrinsics, extrinsics, and TSDF fusion. The fused point cloud is rendered along the target trajectory, VAE-encoded, and converted into conditioning tokens. | `diffsynth/models/memory/geometry_spatial_memory.py` | `train/memory_baselines_basic/run_geometry_spatial_memory_baseline.sh` |
26
+ | State-space | `block_wise_ssm` | Paper-aligned recurrent state attached to selected DiT blocks. Checkpoint keys contain `block_wise_ssm.*`. | `diffsynth/models/memory/block_wise_ssm.py` | `train/memory_baselines_basic/run_ablation_block_wise_ssm_two_chunk.sh` |
27
+ | State-space | `videossm_hybrid` | Legacy VideoSSM hybrid baseline: depthwise temporal-conv state-space-like module. Checkpoint keys contain `videossm_hybrid.*`. | `diffsynth/models/memory/videossm_hybrid.py` | `train/memory_baselines_basic/run_videossm_hybrid_baseline.sh` |
28
+
29
+ ## Naming Rules
30
+
31
+ - Do not describe `SpatialGridMemory` or the existing `spatial_mem` results as the
32
+ geometry-grounded method from arXiv:2506.05284. It is a token-grid baseline.
33
+ - Use **Geometry-grounded Spatial Memory** only when the metadata supplies
34
+ rendered static geometry through `geometry_memory` (or a configured column).
35
+ The geometry extractor is the external reconstruction pipeline: depth and
36
+ cameras → TSDF-fused static point cloud → target-view renders. The model-side
37
+ encoder does not estimate depth itself.
38
+ - Use **Block-wise SSM** only for `--use_block_wise_ssm` / `BlockWiseStateSpaceMemory`.
39
+ - Use **VideoSSM hybrid** only for the legacy `--use_videossm_hybrid` / `HybridStateSpaceMemory` baseline.
40
+ - Use **Context learning** for raw-context capacity rows (`K=1/5/20`), not for compact memory modules.
41
+ - Keep checkpoint folder names stable; `env/memory_baseline_runtime.py` and `inference/unified_inference.py` infer memory profiles from those names.
code/docs/.nojekyll ADDED
File without changes
code/docs/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project page (local preview)
2
+
3
+ The **official** GitHub Pages site is deployed from the **`gh-pages`** branch (root `index.html` + `style.css`), not from this folder.
4
+
5
+ This `docs/` directory mirrors that pink-themed project page so you can preview locally:
6
+
7
+ ```bash
8
+ cd docs
9
+ python -m http.server 18876 --bind 0.0.0.0
10
+ ```
11
+
12
+ Then open `http://localhost:18876/` (with port forwarding if remote).
13
+
14
+ **Live site:** https://echo-team-joy-future-academy-jd.github.io/Echo-Memory/
15
+
16
+ Edit `docs/index.html`, `docs/style.css`, `docs/site.js`, and `docs/assets/`, then run `bash scripts/publish_gh_pages.sh` (or push `main` to trigger CI).
code/docs/assets/echo-memory-paper.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d7b4abd1c79f36e21bafff4d972a9562058b814fd40b0e9225309833f1adf11a
3
+ size 4038848
code/docs/assets/opendomain_revisit/README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Open-Domain Revisit Sources
2
+
3
+ This folder contains the eight held-out first-frame sources used by the
4
+ open-domain revisit probe in the Echo-Memory paper.
5
+
6
+ Each image is treated as a first frame for a short controlled camera-return
7
+ probe. The default prompt used by `eval/v2/revisit_suite` is:
8
+
9
+ ```text
10
+ A toy bear in the same static scene. Preserve the bear appearance and the scene layout after camera revisit.
11
+ ```
12
+
13
+ To run the probe, point `OOD_DIR` here or use the default:
14
+
15
+ ```bash
16
+ export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
17
+ PHASE=stage1 OOD_DIR=assets/opendomain_revisit \
18
+ bash eval/v2/revisit_suite/run_one_click_revisit_eval.sh
19
+ ```
code/docs/assets/paper_cases/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Paper Case Visual Assets
2
+
3
+ This directory contains paper-facing figures copied into the release for direct README rendering.
4
+
5
+ - `figure_1_abs_framework.png`: paper teaser and workflow figure for the project landing page.
6
+ - `figure_2_mem_overview.png`: overview of the memory design matrix.
7
+
8
+ To generate new open-domain revisit videos and evidence frames for a checkpoint, use:
9
+
10
+ ```bash
11
+ PHASE=stage1 OOD_DIR=assets/opendomain_revisit \
12
+ bash eval/v2/revisit_suite/run_one_click_revisit_eval.sh
13
+ ```
code/docs/assets/readme_previews/context_k1_replay_gt.gif ADDED

Git LFS Details

  • SHA256: c06f6c8683c1adc6021207cf7118d266e2adc9dccc43e79eea701b879f55cc35
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
code/docs/assets/readme_previews/context_k5_replay_gt.gif ADDED

Git LFS Details

  • SHA256: 3604e3d46285123871163d2fcb32a14809d5286a7f724b631b2fc425fad9594b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.22 MB
code/docs/assets/readme_previews/dyn_context_k1_replay.gif ADDED

Git LFS Details

  • SHA256: 727a4e1a81f34826d5e87bd11edfab902436a780dd0a5c47d0752d845aa74744
  • Pointer size: 132 Bytes
  • Size of remote file: 1.31 MB
code/docs/assets/readme_previews/dyn_context_k20_replay.gif ADDED

Git LFS Details

  • SHA256: bb3834f1bf597e3da706420bf8fb927b69cdd9db4a3c0528ac4b06369e0ae8b2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.52 MB
code/docs/assets/readme_previews/dyn_context_k5_replay.gif ADDED

Git LFS Details

  • SHA256: 213b241afeccc369593ae708a6357d0f5b8c56501e2d21bc6ede8cf504013166
  • Pointer size: 132 Bytes
  • Size of remote file: 1.26 MB
code/docs/assets/readme_previews/dyn_spatial_memory_replay.gif ADDED

Git LFS Details

  • SHA256: 7b139ed864a3e813aa89bdfa4582bdbea7e19b87a7b6786e6510195ed708b3a0
  • Pointer size: 132 Bytes
  • Size of remote file: 1.59 MB
code/docs/assets/readme_previews/dyn_ssm_blockwise_replay.gif ADDED

Git LFS Details

  • SHA256: 784d33b8ee707a1d2530b26ebee93431d4e4a43a01f72d7323601fd931eebe17
  • Pointer size: 132 Bytes
  • Size of remote file: 1.42 MB
code/docs/assets/readme_previews/dyn_ssm_legacy_replay.gif ADDED

Git LFS Details

  • SHA256: 563305e8b46af407a27ccc3226b735cf4a717049a6f69c7e165c65233e7bb049
  • Pointer size: 132 Bytes
  • Size of remote file: 1.57 MB
code/docs/assets/readme_previews/framepack_len_r4_replay_gt.gif ADDED

Git LFS Details

  • SHA256: af342e45e10c6eba8a0843c6d314ab53c166d56ac441956408ac0bfb2710b598
  • Pointer size: 132 Bytes
  • Size of remote file: 1.59 MB
code/docs/assets/readme_previews/spatial_memory_replay_gt.gif ADDED

Git LFS Details

  • SHA256: 02e00f0c1abba304715ae6821a6c05bef3fd132ceaeb50d8ef2dc9fb77a9ee89
  • Pointer size: 132 Bytes
  • Size of remote file: 1.1 MB
code/docs/assets/readme_previews/ssm_blockwise_replay_gt.gif ADDED

Git LFS Details

  • SHA256: 11d5c93b3ac8a35c94c89be513f3964059ce33ab68e2a0b75ab0ce748b06985c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.1 MB
code/docs/assets/readme_previews/ssm_legacy_replay_gt.gif ADDED

Git LFS Details

  • SHA256: 75b24e6a0d4a944d9dadde6719fd9d523d1d355ab32596f990185aaca7b74da9
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
code/docs/developer.html ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Echo-Memory Developer Guide</title>
7
+ <meta name="description" content="Echo-Memory development guide — workflows, eval, and Cursor vibe coding." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
11
+ <link rel="stylesheet" href="style.css?v=dev-skills-v1" />
12
+ </head>
13
+ <body class="developer-page" data-title-key="dev.meta.title">
14
+ <div class="page-shell page-shell--doc">
15
+ <header class="top-nav top-nav--doc" role="banner">
16
+ <div class="container top-nav__inner top-nav__inner--doc">
17
+ <a class="brand" href="index.html">
18
+ <span class="brand-name">Echo-Memory</span>
19
+ <span class="brand-team">Echo Team</span>
20
+ </a>
21
+ <div class="nav-actions">
22
+ <a class="action-btn action-btn--ghost action-btn--compact" href="index.html" data-i18n="dev.back">← Back to project page</a>
23
+ <button type="button" class="lang-toggle" id="lang-toggle" data-i18n-attr="aria-label:lang.switch" aria-label="Switch language">
24
+ <span data-lang-current>EN</span> / <span data-lang-alt>中文</span>
25
+ </button>
26
+ </div>
27
+ </div>
28
+ </header>
29
+
30
+ <main class="container dev-main">
31
+ <p class="section-kicker" data-i18n="dev.kicker">Development · Cursor</p>
32
+ <h1 class="dev-title" data-i18n="dev.title">Developer Guide</h1>
33
+ <p class="section-lead" data-i18n-html="dev.subtitle"></p>
34
+
35
+ <article class="dev-section card">
36
+ <h2 data-i18n="dev.s1.title">1. What this guide is</h2>
37
+ <ul class="bullet-list" data-i18n-html="dev.s1.body"></ul>
38
+ </article>
39
+
40
+ <article class="dev-section card">
41
+ <h2 data-i18n="dev.s2.title">2. Environment &amp; paths</h2>
42
+ <p data-i18n="dev.s2.intro">Set these before training or eval:</p>
43
+ <pre class="code-block"><code>export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
44
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
45
+ export PYTHONPATH=$PWD:${PYTHONPATH:-}
46
+ export OUTPUT_BASE_ROOT=$PWD/outputs</code></pre>
47
+ <ul class="bullet-list" data-i18n-html="dev.s2.body"></ul>
48
+ </article>
49
+
50
+ <article class="dev-section card">
51
+ <h2 data-i18n="dev.s3.title">3. Code map</h2>
52
+ <div class="dev-table-wrap" data-i18n-html="dev.s3.table"></div>
53
+ </article>
54
+
55
+ <article class="dev-section card">
56
+ <h2 data-i18n="dev.s4.title">4. Common workflows</h2>
57
+ <p data-i18n="dev.s4.trainLabel"><strong>Train one memory row</strong> (from repo root):</p>
58
+ <pre class="code-block"><code>bash train/memory_baselines_basic/run_spatial_memory_baseline.sh
59
+ bash train/context_learning/run_pre_qkv_ctx20.sh</code></pre>
60
+ <p data-i18n="dev.s4.evalLabel"><strong>Smoke eval with a HF checkpoint:</strong></p>
61
+ <pre class="code-block"><code>huggingface-cli download Echo-Team/Echo-Memory \
62
+ context_k1/epoch-0.safetensors --local-dir ./ckpts
63
+ export CKPT=./ckpts/context_k1/epoch-0.safetensors
64
+ bash eval/v2/run_static_consistency_loop_and_revisit.sh</code></pre>
65
+ <p class="section-note" data-i18n-html="dev.s4.note"></p>
66
+ </article>
67
+
68
+ <article class="dev-section card dev-section--accent">
69
+ <h2 data-i18n="dev.s5.title">5. Cursor vibe coding</h2>
70
+ <p data-i18n-html="dev.s5.intro"></p>
71
+ <p class="dev-skills-title" data-i18n="dev.s5.skillsTitle">Project skills</p>
72
+ <div class="dev-table-wrap" data-i18n-html="dev.s5.skills"></div>
73
+ <ul class="bullet-list" data-i18n-html="dev.s5.body"></ul>
74
+ <p data-i18n="dev.s5.promptLabel"><strong>Example prompt</strong></p>
75
+ <pre class="code-block code-block--prompt"><code data-i18n="dev.s5.prompt"></code></pre>
76
+ <p class="section-note" data-i18n-html="dev.s5.note"></p>
77
+ </article>
78
+
79
+ <article class="dev-section card">
80
+ <h2 data-i18n="dev.s6.title">6. Site &amp; release (maintainers)</h2>
81
+ <p data-i18n-html="dev.s6.intro"></p>
82
+ <pre class="code-block"><code>bash scripts/publish_gh_pages.sh</code></pre>
83
+ <p data-i18n-html="dev.s6.body"></p>
84
+ </article>
85
+
86
+ <article class="dev-section card">
87
+ <h2 data-i18n="dev.s7.title">7. Checklist</h2>
88
+ <ul class="bullet-list">
89
+ <li data-i18n="dev.s7.l1">Smoke eval with one HF checkpoint before tagging a release.</li>
90
+ <li data-i18n-html="dev.s7.l2"></li>
91
+ <li data-i18n="dev.s7.l3">Public docs use Echo pool names — no internal paths or benchmark codenames.</li>
92
+ <li data-i18n-html="dev.s7.l4"></li>
93
+ </ul>
94
+ </article>
95
+
96
+ <p class="dev-repo-link" data-i18n-html="dev.footer"></p>
97
+ </main>
98
+
99
+ <footer class="footer">
100
+ <div class="container footer-row">
101
+ <p class="footer-copy" data-i18n="footer.copy">© Echo Team · Joy Future Academy, JD</p>
102
+ </div>
103
+ </footer>
104
+ </div>
105
+
106
+ <script src="i18n.js?v=dev-skills-v1"></script>
107
+ <script src="i18n-runtime.js?v=dev-skills-v1"></script>
108
+ </body>
109
+ </html>
code/docs/i18n-runtime.js ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ "use strict";
3
+
4
+ var STORAGE_KEY = "echo-memory-lang";
5
+ var NAV_MAP = {
6
+ hero: "nav.home",
7
+ overview: "nav.overview",
8
+ design: "nav.design",
9
+ checkpoints: "nav.checkpoints",
10
+ evaluation: "nav.eval",
11
+ evidence: "nav.evidence",
12
+ findings: "nav.findings",
13
+ updates: "nav.updates",
14
+ bibtex: "nav.bibtex"
15
+ };
16
+
17
+ function detectDefaultLang() {
18
+ var saved = localStorage.getItem(STORAGE_KEY);
19
+ if (saved === "en" || saved === "zh") return saved;
20
+ return "en";
21
+ }
22
+
23
+ function dict(lang) {
24
+ return (window.ECHO_I18N && window.ECHO_I18N[lang]) || {};
25
+ }
26
+
27
+ function t(lang, key) {
28
+ var d = dict(lang);
29
+ return Object.prototype.hasOwnProperty.call(d, key) ? d[key] : "";
30
+ }
31
+
32
+ function applyLang(lang) {
33
+ var d = dict(lang);
34
+ if (!Object.keys(d).length) return;
35
+
36
+ document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
37
+
38
+ var descMeta = document.querySelector('meta[name="description"]');
39
+ var pageTitleKey = document.body.getAttribute("data-title-key");
40
+ if (pageTitleKey && d[pageTitleKey]) {
41
+ document.title = d[pageTitleKey];
42
+ } else if (d["meta.title"]) {
43
+ document.title = d["meta.title"];
44
+ }
45
+ if (descMeta) {
46
+ if (pageTitleKey === "dev.meta.title" && d["dev.meta.description"]) {
47
+ descMeta.setAttribute("content", d["dev.meta.description"]);
48
+ } else if (d["meta.description"]) {
49
+ descMeta.setAttribute("content", d["meta.description"]);
50
+ }
51
+ }
52
+
53
+ document.querySelectorAll("[data-i18n]").forEach(function (el) {
54
+ var key = el.getAttribute("data-i18n");
55
+ if (!key || !(key in d)) return;
56
+ el.textContent = d[key];
57
+ });
58
+
59
+ document.querySelectorAll("[data-i18n-html]").forEach(function (el) {
60
+ var key = el.getAttribute("data-i18n-html");
61
+ if (!key || !(key in d)) return;
62
+ el.innerHTML = d[key];
63
+ });
64
+
65
+ document.querySelectorAll("[data-i18n-attr]").forEach(function (el) {
66
+ el.getAttribute("data-i18n-attr").split(";").forEach(function (pair) {
67
+ var parts = pair.split(":");
68
+ if (parts.length !== 2) return;
69
+ var attr = parts[0].trim();
70
+ var key = parts[1].trim();
71
+ if (key in d) el.setAttribute(attr, d[key]);
72
+ });
73
+ });
74
+
75
+ document.querySelectorAll("[data-nav]").forEach(function (el) {
76
+ var navKey = NAV_MAP[el.getAttribute("data-nav")];
77
+ if (navKey && d[navKey]) el.textContent = d[navKey];
78
+ });
79
+
80
+ document.querySelectorAll(".slide-dots button[data-slide]").forEach(function (btn) {
81
+ var navKey = NAV_MAP[btn.getAttribute("data-slide")];
82
+ if (navKey && d[navKey]) {
83
+ btn.setAttribute("data-label", d[navKey]);
84
+ btn.setAttribute("aria-label", d[navKey]);
85
+ }
86
+ });
87
+
88
+ document.querySelectorAll(".qual-chip").forEach(function (chip, idx) {
89
+ var keys = ["evidence.chip1", "evidence.chip2", "evidence.chip3"];
90
+ if (keys[idx] && d[keys[idx]]) chip.textContent = d[keys[idx]];
91
+ });
92
+
93
+ var activeQual = document.querySelector(".qual-chip.is-active");
94
+ var qualCaption = document.querySelector("[data-qual-caption]");
95
+ if (activeQual && qualCaption && activeQual.dataset.captionKey && d[activeQual.dataset.captionKey]) {
96
+ qualCaption.innerHTML = d[activeQual.dataset.captionKey] + ' <span class="zoom-hint">' + (d["zoom.hint"] || "Click to expand") + "</span>";
97
+ } else if (qualCaption && d["evidence.cap1"] && document.querySelector(".qual-chip.is-active") === document.querySelector(".qual-chip")) {
98
+ qualCaption.innerHTML = d["evidence.cap1"];
99
+ }
100
+
101
+ var langCurrent = document.querySelector("[data-lang-current]");
102
+ var langAlt = document.querySelector("[data-lang-alt]");
103
+ if (langCurrent && d["lang.current"]) langCurrent.textContent = d["lang.current"];
104
+ if (langAlt && d["lang.toggle"]) langAlt.textContent = d["lang.toggle"];
105
+
106
+ var toggle = document.getElementById("lang-toggle");
107
+ if (toggle && d["lang.switch"]) toggle.setAttribute("aria-label", d["lang.switch"]);
108
+
109
+ document.querySelectorAll("[data-copy-target]").forEach(function (btn) {
110
+ if (!btn.classList.contains("is-copied") && d["bibtex.copy"]) {
111
+ btn.textContent = d["bibtex.copy"];
112
+ }
113
+ });
114
+
115
+ localStorage.setItem(STORAGE_KEY, lang);
116
+ document.dispatchEvent(new CustomEvent("echo-lang-change", { detail: { lang: lang } }));
117
+ }
118
+
119
+ window.EchoI18n = {
120
+ getLang: function () {
121
+ return localStorage.getItem(STORAGE_KEY) || detectDefaultLang();
122
+ },
123
+ setLang: applyLang,
124
+ t: function (key) {
125
+ return t(window.EchoI18n.getLang(), key);
126
+ }
127
+ };
128
+
129
+ document.addEventListener("DOMContentLoaded", function () {
130
+ var lang = detectDefaultLang();
131
+ applyLang(lang);
132
+
133
+ var toggle = document.getElementById("lang-toggle");
134
+ if (toggle) {
135
+ toggle.addEventListener("click", function () {
136
+ var next = window.EchoI18n.getLang() === "zh" ? "en" : "zh";
137
+ applyLang(next);
138
+ });
139
+ }
140
+ });
141
+ })();
code/docs/i18n.js ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Echo-Memory project page — EN / ZH strings */
2
+ window.ECHO_I18N = {
3
+ en: {
4
+ "meta.title": "Echo-Memory | Echo Team",
5
+ "meta.description": "Echo-Memory: A controlled study of memory mechanisms in action-conditioned world models.",
6
+ "nav.home": "Home",
7
+ "nav.overview": "Overview",
8
+ "nav.design": "Design",
9
+ "nav.checkpoints": "Ckpt",
10
+ "nav.eval": "Eval",
11
+ "nav.evidence": "Qual",
12
+ "nav.findings": "Results",
13
+ "nav.updates": "Updates",
14
+ "nav.bibtex": "BibTeX",
15
+ "nav.developer": "Dev Guide",
16
+ "nav.github": "GitHub",
17
+ "nav.menu": "Menu",
18
+ "lang.toggle": "中文",
19
+ "lang.current": "EN",
20
+ "lang.switch": "Switch language",
21
+ "hero.badge": "Echo Team · Joy Future Academy, JD · June 2026 · CC BY 4.0",
22
+ "hero.title.line2": "A Controlled Study of Memory in Action World Models",
23
+ "hero.subtitle": "When the camera leaves and returns, which memory keeps the <strong>same world</strong> instead of a plausible but different scene?",
24
+ "hero.cta.paper": "Paper",
25
+ "hero.cta.pdf": "PDF",
26
+ "hero.cta.ckpt": "Checkpoints",
27
+ "hero.cta.code": "Code",
28
+ "hero.metric.stars": "GitHub Stars",
29
+ "hero.metric.forks": "Forks",
30
+ "hero.note": "Controlled memory ablations on a shared <strong>Wan</strong> action-to-video stack — reproducible rows, evaluation scripts, and qualitative revisit panels.",
31
+ "hero.authors.summary": "Authors & affiliations",
32
+ "hero.affiliations": "HKU · Joy Future Academy, JD · CUHK · PKU · Fudan · Tsinghua · HKUST · UMich",
33
+ "overview.kicker": "01 · Overview",
34
+ "overview.title": "One backbone, one protocol — only memory changes.",
35
+ "overview.lead": "Echo-Memory holds the video backbone, training recipe, and data protocol fixed, and swaps only the memory module. The goal is to separate <strong>replay fidelity</strong> from <strong>return memory</strong> when the camera leaves and comes back to the same place.",
36
+ "overview.b1": "<strong>Shared stack</strong> — chunk-wise action-conditioned world generation on Wan.",
37
+ "overview.b2": "<strong>Controlled variable</strong> — Context, Compression, Spatial, or State-Space memory.",
38
+ "overview.b3": "<strong>Three probes</strong> — replay metrics, in-domain 180° loop, open-domain edited return.",
39
+ "overview.b4": "<strong>Release</strong> — ablation scripts, GT replay, revisit assets, and paper-aligned figures.",
40
+ "overview.fig.cap": "Controlled memory study over chunk-wise action-world generation. <span class=\"zoom-hint\">Click to expand</span>",
41
+ "overview.fig.alt": "Echo-Memory framework overview",
42
+ "design.kicker": "02 · Memory Design",
43
+ "design.title": "Context · Compression · Spatial · State-Space",
44
+ "design.lead": "All variants plug into the same write–read interface; we only change what is stored and how history is retrieved. A no-memory I2V floor re-generates from the first frame as a lower bound.",
45
+ "design.context.title": "Context",
46
+ "design.context.body": "Raw recent frames at K = 1, 5, or 20 chunks — tests whether longer windows alone stop drift.",
47
+ "design.compression.title": "Compression",
48
+ "design.compression.body": "Learned compact tokens at ratio r = 4 — history without growing raw-frame storage.",
49
+ "design.spatial.title": "Spatial",
50
+ "design.spatial.body": "Explicit spatial read/write state — targets layout, object pose, and viewpoint carry.",
51
+ "design.ssm.title": "State-Space",
52
+ "design.ssm.body": "Block-wise SSM updates — recurrent carry beyond short context windows on revisit.",
53
+ "design.fig.cap": "Four memory families under a shared write–read interface. <span class=\"zoom-hint\">Click to expand</span>",
54
+ "design.fig.alt": "Memory design matrix",
55
+ "ckpt.kicker": "03 · Checkpoints",
56
+ "ckpt.title": "Paper baselines on Hugging Face",
57
+ "ckpt.lead": "Wan 2.1 1.3B memory rows — <strong>epoch-0</strong>, <strong>30,000 steps</strong>, static in-domain pool. Released weights: <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\"><strong>Echo-Team/Echo-Memory</strong></a>",
58
+ "ckpt.th.family": "Family",
59
+ "ckpt.th.row": "Paper row",
60
+ "ckpt.th.path": "HF path",
61
+ "ckpt.th.steps": "Steps",
62
+ "ckpt.label.download": "Download",
63
+ "ckpt.label.eval": "In-domain eval (Echo-Memory repo)",
64
+ "ckpt.note": "Keep the row folder in <code>CKPT</code> — <code>env/memory_baseline_runtime.py</code> infers memory flags from the path. Full index: <a href=\"https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/blob/main/doc/checkpoints.md\" target=\"_blank\" rel=\"noopener noreferrer\">doc/checkpoints.md</a>.",
65
+ "eval.kicker": "04 · Evaluation",
66
+ "eval.title": "Replay · In-domain revisit · Open-domain return",
67
+ "eval.lead": "Each branch asks a different question: Can the model reconstruct the past? Can it close a loop in-domain? After an edited first frame, does it return to the <strong>same world</strong>?",
68
+ "eval.replay.title": "Replay",
69
+ "eval.replay.body": "PSNR, SSIM, LPIPS on chunk-wise reconstruction — measures short-horizon pixel fidelity.",
70
+ "eval.indomain.title": "In-domain",
71
+ "eval.indomain.body": "180° trajectory loop closure with VLM-assisted scoring on held layouts.",
72
+ "eval.opendomain.title": "Open-domain",
73
+ "eval.opendomain.body": "Edited first frames and 45° return probes — stresses object identity and scene persistence.",
74
+ "eval.dynamic.title": "Dynamic SpatialVID",
75
+ "eval.dynamic.body": "Training and inference wrappers are public; the dynamic eval protocol is TODO.",
76
+ "eval.fig.cap": "Replay health vs. return memory under the same stack. <span class=\"zoom-hint\">Click to expand</span>",
77
+ "eval.fig.alt": "Three-branch evaluation summary",
78
+ "evidence.kicker": "05 · Qualitative Evidence",
79
+ "evidence.title": "Return probes expose identity drift.",
80
+ "evidence.lead": "Qualitative panels follow a simple diagnostic: <strong>first frame → leave the view → revisit tail</strong>. We compare whether memory restores the same object, pose, background, and camera geometry — not merely a plausible new scene.",
81
+ "evidence.chip1": "Memory Results",
82
+ "evidence.chip2": "Open-Domain Sweep",
83
+ "evidence.chip3": "Identity Anchors",
84
+ "evidence.cap1": "Representative memory comparisons across variants. <span class=\"zoom-hint\">Click to expand</span>",
85
+ "evidence.dynamic.note": "SpatialVID previews use one selected training scene and the same first frame, prompt, and GT camera trajectory for a 5-second first-chunk replay across all six rows.",
86
+ "findings.kicker": "06 · Main Conclusions",
87
+ "findings.title": "Replay quality ≠ memory quality.",
88
+ "findings.lead": "Replay metrics and return probes do not always agree — a model can look sharp on reconstruction yet fail when the camera returns. Rankings reorder once identity under revisit is measured.",
89
+ "findings.b1": "<strong>Raw context</strong> — more history helps open-domain return more than replay alone.",
90
+ "findings.b2": "<strong>Compression</strong> — compact tokens can preserve replay while losing identity on return.",
91
+ "findings.b3": "<strong>Spatial vs. SSM</strong> — explicit state and block-wise SSM trade off layout carry and long-horizon stability.",
92
+ "findings.b4": "<strong>Takeaway</strong> — treat replay as a health check, not the final memory benchmark.",
93
+ "findings.fig.cap": "Rank shift from replay to return — replay is not the final memory score. <span class=\"zoom-hint\">Click to expand</span>",
94
+ "updates.kicker": "07 · News & Roadmap",
95
+ "updates.title": "Release notes and next steps.",
96
+ "updates.news": "News",
97
+ "updates.roadmap": "Roadmap",
98
+ "updates.news0": "SpatialVID support added: dynamic training/inference recipes, 5-second first-chunk replay previews, and dynamic eval marked as TODO.",
99
+ "updates.news1": "Echo-Memory released: paper on <a href=\"https://arxiv.org/abs/2606.09803\" target=\"_blank\" rel=\"noopener noreferrer\">arXiv</a> (<a href=\"https://arxiv.org/pdf/2606.09803\" target=\"_blank\" rel=\"noopener noreferrer\">PDF</a>), project page, public code, replay/revisit eval assets, and baseline checkpoints on <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\"><strong>Echo-Team/Echo-Memory</strong></a>.",
100
+ "updates.models": "Models",
101
+ "updates.eval": "Eval",
102
+ "updates.rm1": "<strong>Wan 2.1 1.3B</strong> backbone and training recipes",
103
+ "updates.rm2": "Four memory families — Context, Compression, Spatial, State-Space",
104
+ "updates.rm3": "<strong>Dynamic training pool</strong> — SpatialVID subset export & settings",
105
+ "updates.rm4": "<strong>Paper checkpoints</strong> — <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\">Echo-Team/Echo-Memory</a>",
106
+ "updates.rm5": "<strong>Wan 2.2</strong> + multi-scale <strong>5B / 14B</strong>",
107
+ "updates.re1": "<strong>Dynamic eval</strong> beyond static replay/revisit",
108
+ "updates.re2": "More revisit probes and scoring presets",
109
+ "community.title": "Community",
110
+ "community.lead": "Join the Echo-Memory WeChat group for release updates, checkpoint questions, and maintainer coordination.",
111
+ "community.qr.alt": "Echo-Memory WeChat group QR code",
112
+ "community.qr.caption": "Echo-Memory 交流群 · scan to join (QR refreshes periodically)",
113
+ "bibtex.kicker": "08 · Citation",
114
+ "bibtex.title": "BibTeX",
115
+ "bibtex.lead": "Echo-Memory: A Controlled Study of Memory in Action World Models (June 2026). Licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\" target=\"_blank\" rel=\"noopener noreferrer\">CC BY 4.0</a>. Cite the arXiv preprint below.",
116
+ "bibtex.arxiv": "arXiv",
117
+ "bibtex.source": "Source",
118
+ "bibtex.doi": "DOI",
119
+ "bibtex.arxivid": "arXiv ID",
120
+ "bibtex.pdf": "PDF",
121
+ "bibtex.license": "License",
122
+ "bibtex.copy": "Copy",
123
+ "bibtex.copied": "Copied",
124
+ "bibtex.failed": "Failed",
125
+ "footer.copy": "© Echo Team · Joy Future Academy, JD",
126
+ "footer.pp": "Project Page",
127
+ "zoom.hint": "Click to expand",
128
+ "backtop": "Back to top",
129
+ "lightbox.close": "Close",
130
+ "lightbox.label": "Expanded figure",
131
+ "dev.meta.title": "Echo-Memory Developer Guide",
132
+ "dev.meta.description": "Echo-Memory development guide — workflows, eval, and Cursor vibe coding.",
133
+ "dev.kicker": "Development · Cursor",
134
+ "dev.title": "Developer Guide",
135
+ "dev.subtitle": "Hands-on coding, training, eval, and <strong>Cursor vibe coding</strong> for Echo-Memory.",
136
+ "dev.back": "← Back to project page",
137
+ "dev.s1.title": "1. What this guide is",
138
+ "dev.s1.body": "<li><strong>README</strong> — paper overview, quick start, checkpoints, community.</li><li><strong>This guide</strong> — workflows, project <strong>Cursor skills</strong>, Agent tips.</li><li><strong><code>doc/</code></strong> — dataset &amp; checkpoint reference.</li><li><strong><code>.cursor/skills/</code></strong> — Agent skills for train / eval / release.</li>",
139
+ "dev.s2.title": "2. Environment & paths",
140
+ "dev.s2.intro": "Set these before training or eval:",
141
+ "dev.s2.body": "<li><strong>Static in-domain pool</strong> — default root above; see <code>doc/dataset_preprocessing.md</code>.</li><li><strong>Dynamic training pool</strong> — e.g. <code>data/dynamic-memory-dataset</code>; see <code>doc/dynamic_dataset_preprocessing.md</code>.</li><li><strong>Checkpoints</strong> — <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\">Echo-Team/Echo-Memory</a>; index in <code>doc/checkpoints.md</code>.</li>",
142
+ "dev.s3.title": "3. Code map",
143
+ "dev.s3.table": "<table class=\"dev-table\"><thead><tr><th>Path</th><th>Role</th></tr></thead><tbody><tr><td><code>.cursor/skills/</code></td><td>Cursor Agent skills (train / eval / release)</td></tr><tr><td><code>train/memory_baselines_basic/</code></td><td>Spatial / SSM / compression ablations</td></tr><tr><td><code>train/context_learning/</code></td><td>Context K=1/5/20 recipes</td></tr><tr><td><code>eval/v2/</code></td><td>Replay, loop closure, open-domain revisit</td></tr><tr><td><code>env/memory_baseline_runtime.py</code></td><td>Checkpoint → memory profile</td></tr><tr><td><code>diffsynth/</code></td><td>Wan backbone &amp; training stack</td></tr><tr><td><code>docs/</code></td><td>GitHub Pages (project + this guide)</td></tr></tbody></table>",
144
+ "dev.s4.title": "4. Common workflows",
145
+ "dev.s4.trainLabel": "Train one memory row (from repo root):",
146
+ "dev.s4.evalLabel": "Smoke eval with a HF checkpoint:",
147
+ "dev.s4.note": "Keep the row folder name in <code>CKPT</code> so runtime picks the correct memory profile.",
148
+ "dev.s5.title": "5. Cursor vibe coding",
149
+ "dev.s5.intro": "Use <strong>Cursor Agent</strong> (Composer) with the project skills below.",
150
+ "dev.s5.skillsTitle": "Project skills",
151
+ "dev.s5.skills": "<table class=\"dev-table\"><thead><tr><th>Skill</th><th>Use when</th></tr></thead><tbody><tr><td><code>echo-memory-agent</code></td><td>Scope prompts, rules, skill index</td></tr><tr><td><code>echo-memory-train</code></td><td>Memory baselines &amp; context training</td></tr><tr><td><code>echo-memory-eval</code></td><td>Replay / revisit &amp; HF quick checks</td></tr><tr><td><code>echo-memory-release</code></td><td>gh-pages, i18n, checkpoints doc</td></tr></tbody></table><p class=\"section-note\">Paths: <code>.cursor/skills/&lt;name&gt;/SKILL.md</code> — invoke in chat, e.g. <em>use echo-memory-eval to …</em></p>",
152
+ "dev.s5.body": "<li><strong>Scope prompts</strong> — memory family, script, eval branch (<em>replay / in-domain / open-domain</em>).</li><li><strong>Entry scripts</strong> — e.g. <code>run_spatial_memory_baseline.sh</code>, <code>eval/v2/run_basic_replay_gt.sh</code>.</li><li><strong>Rules</strong> — optional <code>.cursor/rules/echo-memory.mdc</code> for pool naming &amp; public doc constraints.</li><li><strong>Ask mode</strong> — trace checkpoint mapping or read <code>diffsynth/</code> without edits.</li>",
153
+ "dev.s5.promptLabel": "Example prompt",
154
+ "dev.s5.prompt": "Add a quick check that downloads context_k1 from Echo-Team/Echo-Memory\nand runs eval/v2/run_basic_replay_gt.sh with the static in-domain pool.\n\nTrace env/memory_baseline_runtime.py spatial_mem → inject flags;\nsummarize in doc/checkpoints.md.",
155
+ "dev.s5.note": "<strong>Public repo hygiene:</strong> never commit upload bash, internal benchmark names, or machine paths. WeChat QR → project page &amp; README only.",
156
+ "dev.s6.title": "6. Site & release (maintainers)",
157
+ "dev.s6.intro": "After editing <code>docs/index.html</code>, <code>docs/style.css</code>, or <code>docs/i18n*.js</code>:",
158
+ "dev.s6.body": "HF weights: Hugging Face UI or <code>hf upload</code> (maintainers only). Bilingual project page: <code>docs/i18n.js</code> + <code>docs/i18n-runtime.js</code>.",
159
+ "dev.s7.title": "7. Checklist",
160
+ "dev.s7.l1": "Smoke eval with one HF checkpoint before tagging a release.",
161
+ "dev.s7.l2": "Verify <code>doc/checkpoints.md</code> matches HF folder names.",
162
+ "dev.s7.l3": "Public docs use Echo pool names — no internal paths or benchmark codenames.",
163
+ "dev.s7.l4": "Run <code>publish_gh_pages.sh</code> after site changes; spot-check EN/中文 on the live page.",
164
+ "dev.footer": "Repo mirror: <a href=\"https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/blob/main/doc/DEVELOPER.md\">doc/DEVELOPER.md</a> · Community QR on <a href=\"index.html#updates\">project page</a>"
165
+ },
166
+ zh: {
167
+ "meta.title": "Echo-Memory | Echo Team",
168
+ "meta.description": "Echo-Memory:动作条件世界模型中记忆机制的对照研究。",
169
+ "nav.home": "首页",
170
+ "nav.overview": "概览",
171
+ "nav.design": "设计",
172
+ "nav.checkpoints": "权重",
173
+ "nav.eval": "评测",
174
+ "nav.evidence": "证据",
175
+ "nav.findings": "结论",
176
+ "nav.updates": "动态",
177
+ "nav.bibtex": "引用",
178
+ "nav.developer": "开发者手册",
179
+ "nav.github": "GitHub",
180
+ "nav.menu": "菜单",
181
+ "lang.toggle": "EN",
182
+ "lang.current": "中文",
183
+ "lang.switch": "切换语言",
184
+ "hero.badge": "Echo Team · Joy Future Academy, JD · 2026 年 6 月 · CC BY 4.0",
185
+ "hero.title.line2": "动作世界模型中记忆机制的对照研究",
186
+ "hero.subtitle": "当镜头离开再返回时,哪种记忆能让模型守住<strong>同一个世界</strong>,而不是生成一个看似合理却不同的场景?",
187
+ "hero.cta.paper": "论文",
188
+ "hero.cta.pdf": "PDF",
189
+ "hero.cta.ckpt": "模型权重",
190
+ "hero.cta.code": "代码",
191
+ "hero.metric.stars": "GitHub Stars",
192
+ "hero.metric.forks": "Forks",
193
+ "hero.note": "在共享 <strong>Wan</strong> 动作到视频栈上进行可控记忆消融——可复现实验行、评测脚本与 revisit 定性面板。",
194
+ "hero.authors.summary": "作者与单位",
195
+ "hero.affiliations": "HKU · Joy Future Academy, JD · CUHK · PKU · Fudan · Tsinghua · HKUST · UMich",
196
+ "overview.kicker": "01 · 概览",
197
+ "overview.title": "同一骨干、同一协议——只换记忆模块。",
198
+ "overview.lead": "Echo-Memory 固定视频骨干、训练配方与数据协议,仅替换记忆模块,以区分镜头离开再返回时的<strong>回放保真度</strong>与<strong>回归记忆</strong>。",
199
+ "overview.b1": "<strong>共享栈</strong> — 基于 Wan 的分块动作条件世界生成。",
200
+ "overview.b2": "<strong>对照变量</strong> — Context、Compression、Spatial 或 State-Space 记忆。",
201
+ "overview.b3": "<strong>三类探针</strong> — 回放指标、域内 180° 闭环、开放域编辑后回归。",
202
+ "overview.b4": "<strong>开源内容</strong> — 消融脚本、GT 回放、revisit 资产与论文对齐图表。",
203
+ "overview.fig.cap": "分块动作世界生成上的可控记忆研究。<span class=\"zoom-hint\">点击放大</span>",
204
+ "overview.fig.alt": "Echo-Memory 框架概览",
205
+ "design.kicker": "02 · 记忆设计",
206
+ "design.title": "Context · Compression · Spatial · State-Space",
207
+ "design.lead": "各变体接入同一 write–read 接口,仅改变存储内容与历史检索方式。无记忆 I2V 下限仅从首帧重生成。",
208
+ "design.context.title": "Context",
209
+ "design.context.body": "保留 K = 1 / 5 / 20 块原始帧 — 测试更长窗口是否足以抑制漂移。",
210
+ "design.compression.title": "Compression",
211
+ "design.compression.body": "比率 r = 4 的紧凑 token — 在不膨胀原始帧存储的情况下保留历史。",
212
+ "design.spatial.title": "Spatial",
213
+ "design.spatial.body": "显式空间读写状态 — 针对布局、物体位姿与视角携带。",
214
+ "design.ssm.title": "State-Space",
215
+ "design.ssm.body": "Block-wise SSM 更新 — 在 revisit 上超越短上下文窗口的递归携带。",
216
+ "design.fig.cap": "共享 write–read 接口下的四类记忆。<span class=\"zoom-hint\">点击放大</span>",
217
+ "design.fig.alt": "记忆设计矩阵",
218
+ "ckpt.kicker": "03 · 模型权重",
219
+ "ckpt.title": "Hugging Face 论文 baseline",
220
+ "ckpt.lead": "Wan 2.1 1.3B 记忆行 — <strong>epoch-0</strong>、<strong>30,000 steps</strong>、静态 in-domain 训练池。已发布权重:<a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\"><strong>Echo-Team/Echo-Memory</strong></a>",
221
+ "ckpt.th.family": "家族",
222
+ "ckpt.th.row": "论文行",
223
+ "ckpt.th.path": "HF 路径",
224
+ "ckpt.th.steps": "步数",
225
+ "ckpt.label.download": "下载",
226
+ "ckpt.label.eval": "域内评测(Echo-Memory 仓库)",
227
+ "ckpt.note": "请在 <code>CKPT</code> 中保留行目录名 — <code>env/memory_baseline_runtime.py</code> 会从路径推断 memory 配置。完整索引:<a href=\"https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/blob/main/doc/checkpoints.md\" target=\"_blank\" rel=\"noopener noreferrer\">doc/checkpoints.md</a>。",
228
+ "eval.kicker": "04 · 评测",
229
+ "eval.title": "回放 · 域内 revisit · 开放域回归",
230
+ "eval.lead": "三个分支回答不同问题:能否重建过去?域内能否闭环?编辑首帧后能否回到<strong>同一个世界</strong>?",
231
+ "eval.replay.title": "回放",
232
+ "eval.replay.body": "分块重建的 PSNR / SSIM / LPIPS — 衡量短程像素保真。",
233
+ "eval.indomain.title": "域内",
234
+ "eval.indomain.body": "180° 轨迹闭环与 VLM 辅助评分。",
235
+ "eval.opendomain.title": "开放域",
236
+ "eval.opendomain.body": "编辑首帧与 45° 回归探针 — 考察物体身份与场景持续性。",
237
+ "eval.dynamic.title": "动态 SpatialVID",
238
+ "eval.dynamic.body": "训练和推理 wrapper 已公开;动态评测协议暂列 TODO。",
239
+ "eval.fig.cap": "同一栈上的回放健康度 vs. 回归记忆。<span class=\"zoom-hint\">点击放大</span>",
240
+ "eval.fig.alt": "三分支评测概览",
241
+ "evidence.kicker": "05 · 定性证据",
242
+ "evidence.title": "回归探针暴露身份漂移。",
243
+ "evidence.lead": "定性面板遵循简单诊断:<strong>首帧 → 离开视角 → revisit 尾部</strong>。我们比较记忆是否恢复同一物体、位姿、背景与相机几何,而非仅生成合理的新场景。",
244
+ "evidence.chip1": "记忆结果",
245
+ "evidence.chip2": "开放域扫描",
246
+ "evidence.chip3": "身份锚点",
247
+ "evidence.cap1": "各变体的代表性记忆对比。<span class=\"zoom-hint\">点击放大</span>",
248
+ "evidence.dynamic.note": "SpatialVID 预览来自一个精选训练场景;六组使用相同首帧、prompt 与 GT 相机轨迹做 5 秒 first-chunk replay。",
249
+ "findings.kicker": "06 · 主要结论",
250
+ "findings.title": "回放质量 ≠ 记忆质量。",
251
+ "findings.lead": "回放指标与回归探针并不总一致 — 重建可以很 sharp,但镜头返回时仍可能失败。一旦测量 revisit 下的身份一致性,排名会重排。",
252
+ "findings.b1": "<strong>原始 Context</strong> — 更长历史对开放域回归的帮助大于单纯回放。",
253
+ "findings.b2": "<strong>Compression</strong> — 紧凑 token 可保回放但在回归时丢失身份。",
254
+ "findings.b3": "<strong>Spatial vs. SSM</strong> — 显式状态与 block-wise SSM 在布局携带与长程稳定性间权衡。",
255
+ "findings.b4": "<strong>要点</strong> — 将回放视为健康检查,而非最终记忆 benchmark。",
256
+ "findings.fig.cap": "从回放到回归的排名变化 — 回放不是最终记忆分数。<span class=\"zoom-hint\">点击放大</span>",
257
+ "updates.kicker": "07 · 新闻与路线图",
258
+ "updates.title": "发布说明与后续计划。",
259
+ "updates.news": "新闻",
260
+ "updates.roadmap": "路线图",
261
+ "updates.news0": "SpatialVID 支持已加入:动态训练/推理脚本、5 秒 first-chunk replay 预览,以及 dynamic eval TODO。",
262
+ "updates.news1": "Echo-Memory 发布:论文上线 <a href=\"https://arxiv.org/abs/2606.09803\" target=\"_blank\" rel=\"noopener noreferrer\">arXiv</a>(<a href=\"https://arxiv.org/pdf/2606.09803\" target=\"_blank\" rel=\"noopener noreferrer\">PDF</a>),同步发布项目页、公开代码、replay/revisit 评测资产,以及 <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\"><strong>Echo-Team/Echo-Memory</strong></a> baseline 权重。",
263
+ "updates.models": "模型",
264
+ "updates.eval": "评测",
265
+ "updates.rm1": "<strong>Wan 2.1 1.3B</strong> 骨干与训练配方",
266
+ "updates.rm2": "四类记忆 — Context、Compression、Spatial、State-Space",
267
+ "updates.rm3": "<strong>Dynamic training pool</strong> — SpatialVID 子集导出与设置",
268
+ "updates.rm4": "<strong>论文权重</strong> — <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\">Echo-Team/Echo-Memory</a>",
269
+ "updates.rm5": "<strong>Wan 2.2</strong> + 多尺度 <strong>5B / 14B</strong>",
270
+ "updates.re1": "静态 replay/revisit 之外的 <strong>动态评测</strong>",
271
+ "updates.re2": "更多 revisit 探针与评分预设",
272
+ "community.title": "社区交流",
273
+ "community.lead": "扫码加入 Echo-Memory 微信群,获取发布更新、权重使用与维护协调信息。",
274
+ "community.qr.alt": "Echo-Memory 微信群二维码",
275
+ "community.qr.caption": "Echo-Memory 交流群 · 扫码加入(二维码会定期更新)",
276
+ "bibtex.kicker": "08 · 引用",
277
+ "bibtex.title": "BibTeX",
278
+ "bibtex.lead": "Echo-Memory: A Controlled Study of Memory in Action World Models(2026 年 6 月)。许可:<a href=\"https://creativecommons.org/licenses/by/4.0/\" target=\"_blank\" rel=\"noopener noreferrer\">CC BY 4.0</a>。请使用下方 arXiv BibTeX 引用。",
279
+ "bibtex.arxiv": "arXiv",
280
+ "bibtex.source": "来源",
281
+ "bibtex.doi": "DOI",
282
+ "bibtex.arxivid": "arXiv ID",
283
+ "bibtex.pdf": "PDF",
284
+ "bibtex.license": "许可",
285
+ "bibtex.copy": "复制",
286
+ "bibtex.copied": "已复制",
287
+ "bibtex.failed": "失败",
288
+ "footer.copy": "© Echo Team · Joy Future Academy, JD",
289
+ "footer.pp": "项目页",
290
+ "zoom.hint": "点击放大",
291
+ "backtop": "回到顶部",
292
+ "lightbox.close": "关闭",
293
+ "lightbox.label": "放大图表",
294
+ "dev.meta.title": "Echo-Memory 开发者指南",
295
+ "dev.meta.description": "Echo-Memory 开发指南 — 工作流、评测与 Cursor 协作编程。",
296
+ "dev.kicker": "开发 · Cursor",
297
+ "dev.title": "开发者指南",
298
+ "dev.subtitle": "Echo-Memory 实战开发、训练评测与 <strong>Cursor 协作编程</strong>。",
299
+ "dev.back": "← 返回项目页",
300
+ "dev.s1.title": "1. 本指南定位",
301
+ "dev.s1.body": "<li><strong>README</strong> — 论文概览、快速上手、权重、社区。</li><li><strong>本页</strong> — 工作流、项目 <strong>Cursor skills</strong>、Agent 技巧。</li><li><strong><code>doc/</code></strong> — 数据集与权重参考。</li><li><strong><code>.cursor/skills/</code></strong> — 训练 / 评测 / 发布类 Agent 技能。</li>",
302
+ "dev.s2.title": "2. 环境与路径",
303
+ "dev.s2.intro": "训练或评测前设置:",
304
+ "dev.s2.body": "<li><strong>Static in-domain pool</strong> — 默认路径见上;详见 <code>doc/dataset_preprocessing.md</code>。</li><li><strong>Dynamic training pool</strong> — 如 <code>data/dynamic-memory-dataset</code>;详见 <code>doc/dynamic_dataset_preprocessing.md</code>。</li><li><strong>权重</strong> — <a href=\"https://huggingface.co/Echo-Team/Echo-Memory\" target=\"_blank\" rel=\"noopener noreferrer\">Echo-Team/Echo-Memory</a>;索引见 <code>doc/checkpoints.md</code>。</li>",
305
+ "dev.s3.title": "3. 代码地图",
306
+ "dev.s3.table": "<table class=\"dev-table\"><thead><tr><th>路径</th><th>作用</th></tr></thead><tbody><tr><td><code>.cursor/skills/</code></td><td>Cursor Agent 技能(训练 / 评测 / 发布)</td></tr><tr><td><code>train/memory_baselines_basic/</code></td><td>Spatial / SSM / 压缩消融</td></tr><tr><td><code>train/context_learning/</code></td><td>Context K=1/5/20 配方</td></tr><tr><td><code>eval/v2/</code></td><td>回放、闭环、开放域 revisit</td></tr><tr><td><code>env/memory_baseline_runtime.py</code></td><td>权重 → 记忆配置</td></tr><tr><td><code>diffsynth/</code></td><td>Wan 骨干与训练栈</td></tr><tr><td><code>docs/</code></td><td>GitHub Pages(项目页 + 本指南)</td></tr></tbody></table>",
307
+ "dev.s4.title": "4. 常用工作流",
308
+ "dev.s4.trainLabel": "训练一条 memory 行(仓库根目录):",
309
+ "dev.s4.evalLabel": "用 HF 权重做 quick eval:",
310
+ "dev.s4.note": "<code>CKPT</code> 路径需保留行目录名,以便 runtime 匹配记忆配置。",
311
+ "dev.s5.title": "5. Cursor 协作编程",
312
+ "dev.s5.intro": "使用 <strong>Cursor Agent</strong>(Composer)配合下方项目 skills。",
313
+ "dev.s5.skillsTitle": "项目 Skills",
314
+ "dev.s5.skills": "<table class=\"dev-table\"><thead><tr><th>Skill</th><th>适用场景</th></tr></thead><tbody><tr><td><code>echo-memory-agent</code></td><td>Prompt 范围、Rules、技能索引</td></tr><tr><td><code>echo-memory-train</code></td><td>Memory baseline 与 Context 训练</td></tr><tr><td><code>echo-memory-eval</code></td><td>回放 / revisit 与 HF quick check</td></tr><tr><td><code>echo-memory-release</code></td><td>gh-pages、i18n、权重文档</td></tr></tbody></table><p class=\"section-note\">路径:<code>.cursor/skills/&lt;name&gt;/SKILL.md</code> — 在对话中引用,如 <em>use echo-memory-eval 来 …</em></p>",
315
+ "dev.s5.body": "<li><strong>明确范围</strong> — memory 家族、脚本、评测分支(<em>replay / in-domain / open-domain</em>)。</li><li><strong>入口脚本</strong> — 如 <code>run_spatial_memory_baseline.sh</code>、<code>eval/v2/run_basic_replay_gt.sh</code>。</li><li><strong>Rules</strong> — 可选 <code>.cursor/rules/echo-memory.mdc</code> 约束池命名与公开文档。</li><li><strong>Ask 模式</strong> — 追踪 checkpoint 映射或阅读 <code>diffsynth/</code>,不改代码。</li>",
316
+ "dev.s5.promptLabel": "示例 Prompt",
317
+ "dev.s5.prompt": "添加 quick check:从 Echo-Team/Echo-Memory 下载 context_k1,\n用 static in-domain pool 跑 eval/v2/run_basic_replay_gt.sh。\n\n追踪 env/memory_baseline_runtime.py 如何把 spatial_mem\n权重映射到 inject 标志,并在 doc/checkpoints.md 摘要说明。",
318
+ "dev.s5.note": "<strong>公开仓库规范:</strong> 勿提交上传脚本、内部 benchmark 名、本机路径。微信群二维码仅在项目页与 README。",
319
+ "dev.s6.title": "6. 站点与发布(维护者)",
320
+ "dev.s6.intro": "修改 <code>docs/index.html</code>、<code>docs/style.css</code> 或 <code>docs/i18n*.js</code> 后:",
321
+ "dev.s6.body": "HF 权重:网页或 <code>hf upload</code> 更新(仅维护者)。项目页双语:<code>docs/i18n.js</code> + <code>docs/i18n-runtime.js</code>。",
322
+ "dev.s7.title": "7. 检查清单",
323
+ "dev.s7.l1": "发版前用至少一个 HF 权重跑 quick eval。",
324
+ "dev.s7.l2": "确认 <code>doc/checkpoints.md</code> 与 HF 目录名���致。",
325
+ "dev.s7.l3": "公开文档使用 Echo 池命名 — 无内部路径或 benchmark 代号。",
326
+ "dev.s7.l4": "改站点后运行 <code>publish_gh_pages.sh</code>,检查线上 EN/中文 切换。",
327
+ "dev.footer": "仓库副本:<a href=\"https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/blob/main/doc/DEVELOPER.md\">doc/DEVELOPER.md</a> · 社区二维码见 <a href=\"index.html#updates\">项目页</a>"
328
+ }
329
+ };
code/docs/index.html ADDED
@@ -0,0 +1,644 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Echo-Memory | Echo Team</title>
7
+ <meta
8
+ name="description"
9
+ content="Echo-Memory: A controlled study of memory mechanisms in action-conditioned world models."
10
+ />
11
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
13
+ <link
14
+ href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
15
+ rel="stylesheet"
16
+ />
17
+ <link rel="stylesheet" href="style.css?v=dev-i18n-v1" />
18
+ </head>
19
+ <body>
20
+ <div class="scroll-progress" aria-hidden="true"><span class="scroll-progress__bar"></span></div>
21
+ <!-- arXiv: https://arxiv.org/abs/2606.09803 -->
22
+ <div class="page-shell">
23
+ <div class="ambient-bg" aria-hidden="true">
24
+ <div class="ambient-orb ambient-orb--rose"></div>
25
+ <div class="ambient-orb ambient-orb--blush"></div>
26
+ <div class="ambient-orb ambient-orb--pearl"></div>
27
+ <div class="ambient-shimmer"></div>
28
+ </div>
29
+ <header class="top-nav" role="banner">
30
+ <div class="container top-nav__inner">
31
+ <a class="brand" href="#hero">
32
+ <span class="brand-name">Echo-Memory</span>
33
+ <span class="brand-team">Echo Team</span>
34
+ </a>
35
+ <nav class="nav-links" aria-label="Sections">
36
+ <a class="nav-link" href="#hero" data-nav="hero">Home</a>
37
+ <a class="nav-link" href="#overview" data-nav="overview">Overview</a>
38
+ <a class="nav-link" href="#design" data-nav="design">Design</a>
39
+ <a class="nav-link" href="#checkpoints" data-nav="checkpoints">Checkpoints</a>
40
+ <a class="nav-link" href="#evaluation" data-nav="evaluation">Eval</a>
41
+ <a class="nav-link" href="#evidence" data-nav="evidence">Evidence</a>
42
+ <a class="nav-link" href="#findings" data-nav="findings">Results</a>
43
+ <a class="nav-link" href="#updates" data-nav="updates">Updates</a>
44
+ <a class="nav-link" href="#bibtex" data-nav="bibtex">BibTeX</a>
45
+ </nav>
46
+ <div class="nav-actions">
47
+ <a class="action-btn action-btn--ghost" href="developer.html" data-i18n="nav.developer">Developer Guide</a>
48
+ <button type="button" class="lang-toggle" id="lang-toggle" data-i18n-attr="aria-label:lang.switch" aria-label="Switch language">
49
+ <span data-lang-current>EN</span> / <span data-lang-alt>中文</span>
50
+ </button>
51
+ <a
52
+ class="action-btn"
53
+ href="https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory"
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ data-i18n="nav.github"
57
+ >GitHub</a>
58
+ <button
59
+ type="button"
60
+ class="nav-toggle"
61
+ aria-expanded="false"
62
+ aria-controls="nav-menu-mobile"
63
+ id="nav-toggle"
64
+ >
65
+ <span class="sr-only">Menu</span>
66
+ <span></span><span></span><span></span>
67
+ </button>
68
+ </div>
69
+ </div>
70
+ <div class="nav-mobile container" id="nav-menu-mobile" hidden>
71
+ <a class="nav-link" href="#hero" data-nav="hero">Home</a>
72
+ <a class="nav-link" href="#overview" data-nav="overview">Overview</a>
73
+ <a class="nav-link" href="#design" data-nav="design">Design</a>
74
+ <a class="nav-link" href="#checkpoints" data-nav="checkpoints">Checkpoints</a>
75
+ <a class="nav-link" href="#evaluation" data-nav="evaluation">Eval</a>
76
+ <a class="nav-link" href="#evidence" data-nav="evidence">Evidence</a>
77
+ <a class="nav-link" href="#findings" data-nav="findings">Results</a>
78
+ <a class="nav-link" href="#updates" data-nav="updates">Updates</a>
79
+ <a class="nav-link" href="developer.html" data-i18n="nav.developer">Developer Guide</a>
80
+ <a class="nav-link" href="#bibtex" data-nav="bibtex">BibTeX</a>
81
+ </div>
82
+ </header>
83
+
84
+ <aside class="slide-dots" aria-label="Section navigation">
85
+ <button type="button" class="is-active" data-slide="hero" data-label="Home" aria-label="Home"></button>
86
+ <button type="button" data-slide="overview" data-label="Overview" aria-label="Overview"></button>
87
+ <button type="button" data-slide="design" data-label="Design" aria-label="Design"></button>
88
+ <button type="button" data-slide="checkpoints" data-label="Checkpoints" aria-label="Checkpoints"></button>
89
+ <button type="button" data-slide="evaluation" data-label="Eval" aria-label="Evaluation"></button>
90
+ <button type="button" data-slide="evidence" data-label="Evidence" aria-label="Evidence"></button>
91
+ <button type="button" data-slide="findings" data-label="Results" aria-label="Results"></button>
92
+ <button type="button" data-slide="updates" data-label="Updates" aria-label="Updates"></button>
93
+ <button type="button" data-slide="bibtex" data-label="BibTeX" aria-label="BibTeX"></button>
94
+ </aside>
95
+
96
+ <button type="button" class="back-top" id="back-top" data-i18n-attr="aria-label:backtop" aria-label="Back to top" hidden>
97
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
98
+ <path d="M12 19V5M12 5l-6 6M12 5l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
99
+ </svg>
100
+ </button>
101
+
102
+ <div class="lightbox" id="lightbox" hidden role="dialog" aria-modal="true" data-i18n-attr="aria-label:lightbox.label" aria-label="Expanded figure">
103
+ <button type="button" class="lightbox__close" data-i18n-attr="aria-label:lightbox.close" aria-label="Close">&times;</button>
104
+ <figure class="lightbox__inner">
105
+ <img src="" alt="" id="lightbox-img" />
106
+ <figcaption id="lightbox-caption"></figcaption>
107
+ </figure>
108
+ </div>
109
+
110
+ <main id="main-content">
111
+ <section id="hero" class="section section--compact reveal" data-section="hero">
112
+ <div class="container hero hero--single">
113
+ <div>
114
+ <div class="hero-badge" data-i18n="hero.badge">Echo Team · Joy Future Academy, JD · June 2026 · CC BY 4.0</div>
115
+ <h1 class="hero-title">
116
+ <span class="hero-highlight">Echo-Memory</span>
117
+ <br />
118
+ <span data-i18n="hero.title.line2">A Controlled Study of Memory in Action World Models</span>
119
+ </h1>
120
+ <p class="hero-subtitle" data-i18n-html="hero.subtitle">
121
+ When the camera leaves and returns, which memory keeps the <strong>same world</strong>
122
+ instead of a plausible but different scene?
123
+ </p>
124
+ <div class="hero-cta-group">
125
+ <a
126
+ class="cta cta--primary"
127
+ href="https://arxiv.org/abs/2606.09803"
128
+ target="_blank"
129
+ rel="noopener noreferrer"
130
+ data-i18n="hero.cta.paper"
131
+ >Paper</a>
132
+ <a
133
+ class="cta cta--secondary"
134
+ href="https://arxiv.org/pdf/2606.09803"
135
+ target="_blank"
136
+ rel="noopener noreferrer"
137
+ data-i18n="hero.cta.pdf"
138
+ >PDF</a>
139
+ <a
140
+ class="cta cta--secondary"
141
+ href="https://huggingface.co/Echo-Team/Echo-Memory"
142
+ target="_blank"
143
+ rel="noopener noreferrer"
144
+ data-i18n="hero.cta.ckpt"
145
+ >Checkpoints</a>
146
+ <a
147
+ class="cta cta--secondary"
148
+ href="https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory"
149
+ target="_blank"
150
+ rel="noopener noreferrer"
151
+ data-i18n="hero.cta.code"
152
+ >Code</a>
153
+ </div>
154
+
155
+ <div class="metrics-grid metrics-grid--duo">
156
+ <a
157
+ class="metric-card"
158
+ href="https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/stargazers"
159
+ target="_blank"
160
+ rel="noopener noreferrer"
161
+ data-badge-url="https://img.shields.io/github/stars/Echo-Team-Joy-Future-Academy-JD/Echo-Memory.json"
162
+ >
163
+ <span class="metric-label" data-i18n="hero.metric.stars">GitHub Stars</span>
164
+ <strong class="metric-value" data-metric-value data-fallback="—">—</strong>
165
+ </a>
166
+ <a
167
+ class="metric-card"
168
+ href="https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/network/members"
169
+ target="_blank"
170
+ rel="noopener noreferrer"
171
+ data-badge-url="https://img.shields.io/github/forks/Echo-Team-Joy-Future-Academy-JD/Echo-Memory.json"
172
+ >
173
+ <span class="metric-label" data-i18n="hero.metric.forks">Forks</span>
174
+ <strong class="metric-value" data-metric-value data-fallback="—">—</strong>
175
+ </a>
176
+ </div>
177
+
178
+ <p class="hero-note" data-i18n-html="hero.note">
179
+ Controlled memory ablations on a shared <strong>Wan</strong> action-to-video stack —
180
+ reproducible rows, evaluation scripts, and qualitative revisit panels.
181
+ </p>
182
+
183
+ <details class="authors-fold">
184
+ <summary data-i18n="hero.authors.summary">Authors &amp; affiliations</summary>
185
+ <p class="authors">
186
+ Wayne King, Zeyue Xue, Yuxuan Bian, Jie Huang, Haoran Li, Yaowei Li,
187
+ Yaofeng Su, Yuming Li, Haoyu Wang, Shiyi Zhang, Songchun Zhang,
188
+ Yuwei Niu, Sihan Xu, Junhao Zhuang, Haoyang Huang, Nan Duan
189
+ </p>
190
+ <p class="affiliations" data-i18n="hero.affiliations">
191
+ HKU · Joy Future Academy, JD · CUHK · PKU · Fudan · Tsinghua · HKUST · UMich
192
+ </p>
193
+ </details>
194
+ </div>
195
+ </div>
196
+ </section>
197
+
198
+ <section id="overview" class="section reveal" data-section="overview">
199
+ <div class="container">
200
+ <p class="section-kicker" data-i18n="overview.kicker">01 · Overview</p>
201
+ <h2 class="section-title" data-i18n="overview.title">One backbone, one protocol — only memory changes.</h2>
202
+ <p class="section-lead" data-i18n-html="overview.lead">
203
+ Echo-Memory holds the video backbone, training recipe, and data protocol fixed, and
204
+ swaps only the memory module. The goal is to separate <strong>replay fidelity</strong>
205
+ from <strong>return memory</strong> when the camera leaves and comes back to the same place.
206
+ </p>
207
+ <ul class="bullet-list">
208
+ <li data-i18n-html="overview.b1"><strong>Shared stack</strong> — chunk-wise action-conditioned world generation on Wan.</li>
209
+ <li data-i18n-html="overview.b2"><strong>Controlled variable</strong> — Context, Compression, Spatial, or State-Space memory.</li>
210
+ <li data-i18n-html="overview.b3"><strong>Three probes</strong> — replay metrics, in-domain 180° loop, open-domain edited return.</li>
211
+ <li data-i18n-html="overview.b4"><strong>Release</strong> — ablation scripts, GT replay, revisit assets, and paper-aligned figures.</li>
212
+ </ul>
213
+ <figure class="section-figure" data-zoomable>
214
+ <img src="assets/figure_1_abs_framework.png?v=fig1-crop" data-i18n-attr="alt:overview.fig.alt" alt="Echo-Memory framework overview" />
215
+ <figcaption data-i18n-html="overview.fig.cap">Controlled memory study over chunk-wise action-world generation. <span class="zoom-hint">Click to expand</span></figcaption>
216
+ </figure>
217
+ </div>
218
+ </section>
219
+
220
+ <section id="design" class="section reveal" data-section="design">
221
+ <div class="container">
222
+ <p class="section-kicker" data-i18n="design.kicker">02 · Memory Design</p>
223
+ <h2 class="section-title" data-i18n="design.title">Context · Compression · Spatial · State-Space</h2>
224
+ <p class="section-lead" data-i18n="design.lead">
225
+ All variants plug into the same write–read interface; we only change what is stored and how
226
+ history is retrieved. A no-memory I2V floor re-generates from the first frame as a lower bound.
227
+ </p>
228
+ <div class="feature-grid">
229
+ <article class="card card--accent" data-accent="context">
230
+ <div class="card-icon" aria-hidden="true">
231
+ <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 9h10M7 13h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
232
+ </div>
233
+ <h3 data-i18n="design.context.title">Context</h3>
234
+ <p data-i18n="design.context.body">Raw recent frames at K = 1, 5, or 20 chunks — tests whether longer windows alone stop drift.</p>
235
+ </article>
236
+ <article class="card card--accent" data-accent="compression">
237
+ <div class="card-icon" aria-hidden="true">
238
+ <svg viewBox="0 0 24 24" fill="none"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z" stroke="currentColor" stroke-width="1.6"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" stroke="currentColor" stroke-width="1.6"/></svg>
239
+ </div>
240
+ <h3 data-i18n="design.compression.title">Compression</h3>
241
+ <p data-i18n="design.compression.body">Learned compact tokens at ratio r = 4 — history without growing raw-frame storage.</p>
242
+ </article>
243
+ <article class="card card--accent" data-accent="spatial">
244
+ <div class="card-icon" aria-hidden="true">
245
+ <svg viewBox="0 0 24 24" fill="none"><path d="M4 8l8-4 8 4v8l-8 4-8-4V8z" stroke="currentColor" stroke-width="1.6"/><path d="M12 4v16M4 8l8 4 8-4" stroke="currentColor" stroke-width="1.6"/></svg>
246
+ </div>
247
+ <h3 data-i18n="design.spatial.title">Spatial</h3>
248
+ <p data-i18n="design.spatial.body">Explicit spatial read/write state — targets layout, object pose, and viewpoint carry.</p>
249
+ </article>
250
+ <article class="card card--accent" data-accent="ssm">
251
+ <div class="card-icon" aria-hidden="true">
252
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 12a7 7 0 0114 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M8 12h8M16 9l2 3-2 3M8 9l-2 3 2 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
253
+ </div>
254
+ <h3 data-i18n="design.ssm.title">State-Space</h3>
255
+ <p data-i18n="design.ssm.body">Block-wise SSM updates — recurrent carry beyond short context windows on revisit.</p>
256
+ </article>
257
+ </div>
258
+ <figure class="section-figure" data-zoomable>
259
+ <img src="assets/figure_2_mem_overview.png" data-i18n-attr="alt:design.fig.alt" alt="Memory design matrix" />
260
+ <figcaption data-i18n-html="design.fig.cap">Four memory families under a shared write–read interface. <span class="zoom-hint">Click to expand</span></figcaption>
261
+ </figure>
262
+ </div>
263
+ </section>
264
+
265
+ <section id="checkpoints" class="section reveal" data-section="checkpoints">
266
+ <div class="container">
267
+ <p class="section-kicker" data-i18n="ckpt.kicker">03 · Checkpoints</p>
268
+ <h2 class="section-title" data-i18n="ckpt.title">Paper baselines on Hugging Face</h2>
269
+ <p class="section-lead" data-i18n-html="ckpt.lead">
270
+ Wan 2.1 1.3B memory rows — <strong>epoch-0</strong>, <strong>30,000 steps</strong>, static in-domain pool.
271
+ Released weights:
272
+ <a href="https://huggingface.co/Echo-Team/Echo-Memory" target="_blank" rel="noopener noreferrer"><strong>Echo-Team/Echo-Memory</strong></a>
273
+ </p>
274
+ <div class="table-wrap">
275
+ <table class="ckpt-table">
276
+ <thead>
277
+ <tr>
278
+ <th data-i18n="ckpt.th.family">Family</th>
279
+ <th data-i18n="ckpt.th.row">Paper row</th>
280
+ <th data-i18n="ckpt.th.path">HF path</th>
281
+ <th data-i18n="ckpt.th.steps">Steps</th>
282
+ </tr>
283
+ </thead>
284
+ <tbody>
285
+ <tr><td>Raw context</td><td>Context K=1</td><td><code>context_k1/epoch-0.safetensors</code></td><td>30,000</td></tr>
286
+ <tr><td>Raw context</td><td>Context K=20</td><td>TODO</td><td>TODO</td></tr>
287
+ <tr><td>Spatial</td><td>Spatial Memory</td><td>TODO</td><td>TODO</td></tr>
288
+ <tr><td>State-space</td><td>Block-wise SSM</td><td>TODO</td><td>TODO</td></tr>
289
+ <tr><td>State-space</td><td>Legacy Hybrid</td><td>TODO</td><td>TODO</td></tr>
290
+ <tr><td>Spatial</td><td>concat text (abl.)</td><td>TODO</td><td>TODO</td></tr>
291
+ <tr><td>Spatial</td><td>inject none (abl.)</td><td>TODO</td><td>TODO</td></tr>
292
+ <tr><td>Spatial</td><td>cross-attn t32 (abl.)</td><td>TODO</td><td>TODO</td></tr>
293
+ <tr><td>State-space</td><td>SSM ctx1/e4/h21</td><td>TODO</td><td>TODO</td></tr>
294
+ <tr><td>State-space</td><td>SSM ctx5/e1/h21</td><td>TODO</td><td>TODO</td></tr>
295
+ <tr><td>State-space</td><td>SSM ctx5/e4/h81</td><td>TODO</td><td>TODO</td></tr>
296
+ </tbody>
297
+ </table>
298
+ </div>
299
+ <div class="code-block-wrap">
300
+ <p class="code-block-label" data-i18n="ckpt.label.download">Download</p>
301
+ <pre class="code-block"><code>huggingface-cli download Echo-Team/Echo-Memory context_k1/epoch-0.safetensors --local-dir ./ckpts</code></pre>
302
+ </div>
303
+ <div class="code-block-wrap">
304
+ <p class="code-block-label" data-i18n="ckpt.label.eval">In-domain eval (Echo-Memory repo)</p>
305
+ <pre class="code-block"><code>export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
306
+ export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
307
+ export CKPT=./ckpts/context_k1/epoch-0.safetensors
308
+ bash eval/v2/run_static_consistency_loop_and_revisit.sh</code></pre>
309
+ </div>
310
+ <p class="section-note" data-i18n-html="ckpt.note">
311
+ Keep the row folder in <code>CKPT</code> — <code>env/memory_baseline_runtime.py</code> infers memory flags from the path.
312
+ Full index:
313
+ <a href="https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory/blob/main/doc/checkpoints.md" target="_blank" rel="noopener noreferrer">doc/checkpoints.md</a>.
314
+ </p>
315
+ </div>
316
+ </section>
317
+
318
+ <section id="evaluation" class="section section--center reveal" data-section="evaluation">
319
+ <div class="container">
320
+ <p class="section-kicker" data-i18n="eval.kicker">04 · Evaluation</p>
321
+ <h2 class="section-title" data-i18n="eval.title">Replay · In-domain revisit · Open-domain return</h2>
322
+ <p class="section-lead" data-i18n-html="eval.lead">
323
+ Each branch asks a different question: Can the model reconstruct the past? Can it close a loop
324
+ in-domain? After an edited first frame, does it return to the <strong>same world</strong>?
325
+ </p>
326
+ <div class="feature-grid feature-grid--three">
327
+ <article class="card">
328
+ <h3 data-i18n="eval.replay.title">Replay</h3>
329
+ <p data-i18n="eval.replay.body">PSNR, SSIM, LPIPS on chunk-wise reconstruction — measures short-horizon pixel fidelity.</p>
330
+ </article>
331
+ <article class="card">
332
+ <h3 data-i18n="eval.indomain.title">In-domain</h3>
333
+ <p data-i18n="eval.indomain.body">180° trajectory loop closure with VLM-assisted scoring on held layouts.</p>
334
+ </article>
335
+ <article class="card">
336
+ <h3 data-i18n="eval.opendomain.title">Open-domain</h3>
337
+ <p data-i18n="eval.opendomain.body">Edited first frames and 45° return probes — stresses object identity and scene persistence.</p>
338
+ </article>
339
+ <article class="card">
340
+ <h3 data-i18n="eval.dynamic.title">Dynamic SpatialVID</h3>
341
+ <p data-i18n="eval.dynamic.body">Training and inference wrappers are public; the dynamic eval protocol is TODO.</p>
342
+ </article>
343
+ </div>
344
+ <figure class="section-figure" data-zoomable>
345
+ <img src="assets/figure_3_mem_eval.png" data-i18n-attr="alt:eval.fig.alt" alt="Three-branch evaluation summary" />
346
+ <figcaption data-i18n-html="eval.fig.cap">Replay health vs. return memory under the same stack. <span class="zoom-hint">Click to expand</span></figcaption>
347
+ </figure>
348
+ </div>
349
+ </section>
350
+
351
+ <section id="evidence" class="section section--center reveal" data-section="evidence">
352
+ <div class="container">
353
+ <p class="section-kicker" data-i18n="evidence.kicker">05 · Qualitative Evidence</p>
354
+ <h2 class="section-title" data-i18n="evidence.title">Return probes expose identity drift.</h2>
355
+ <p class="section-lead" data-i18n-html="evidence.lead">
356
+ Qualitative panels follow a simple diagnostic: <strong>first frame → leave the view → revisit tail</strong>.
357
+ We compare whether memory restores the same object, pose, background, and camera geometry —
358
+ not merely a plausible new scene.
359
+ </p>
360
+ <div class="qual-viewer" data-qual-viewer>
361
+ <figure class="qual-stage" data-zoomable>
362
+ <div class="qual-stage__media">
363
+ <img
364
+ data-qual-image
365
+ class="is-active"
366
+ src="assets/representative_memory_results.png"
367
+ alt="Representative memory comparisons"
368
+ />
369
+ </div>
370
+ <figcaption data-qual-caption data-i18n-html="evidence.cap1">Representative memory comparisons across variants. <span class="zoom-hint">Click to expand</span></figcaption>
371
+ </figure>
372
+ <div class="qual-chips" role="tablist" aria-label="Evidence panels">
373
+ <button
374
+ class="qual-chip is-active"
375
+ type="button"
376
+ role="tab"
377
+ aria-selected="true"
378
+ data-src="assets/representative_memory_results.png"
379
+ data-alt="Representative memory comparisons"
380
+ data-caption-key="evidence.cap1"
381
+ data-caption="Representative memory comparisons across variants."
382
+ >Memory Results</button>
383
+ <button
384
+ class="qual-chip"
385
+ type="button"
386
+ role="tab"
387
+ aria-selected="false"
388
+ data-src="assets/representative_sweep_panel.png"
389
+ data-alt="Open-domain return sweep"
390
+ data-caption="Open-domain return sweep across the design matrix."
391
+ >Open-Domain Sweep</button>
392
+ <button
393
+ class="qual-chip"
394
+ type="button"
395
+ role="tab"
396
+ aria-selected="false"
397
+ data-src="assets/open_domain_revisit_panel.png"
398
+ data-alt="Edited first frames"
399
+ data-caption="Edited first frames for open-domain return probes."
400
+ >Identity Anchors</button>
401
+ </div>
402
+ </div>
403
+ <div class="demo-grid" aria-label="Static and SpatialVID replay demos">
404
+ <h3 class="demo-grid__label">Static Replay</h3>
405
+ <figure>
406
+ <img src="assets/readme_previews/context_k1_replay_gt.gif" alt="Static Context K=1 replay">
407
+ <figcaption>Context K=1</figcaption>
408
+ </figure>
409
+ <figure>
410
+ <img src="assets/readme_previews/context_k5_replay_gt.gif" alt="Static Context K=5 replay">
411
+ <figcaption>Context K=5</figcaption>
412
+ </figure>
413
+ <figure>
414
+ <img src="assets/readme_previews/framepack_len_r4_replay_gt.gif" alt="Static Compression r=4 replay">
415
+ <figcaption>Compression r = 4</figcaption>
416
+ </figure>
417
+ <figure>
418
+ <img src="assets/readme_previews/spatial_memory_replay_gt.gif" alt="Static Spatial Memory replay">
419
+ <figcaption>Spatial Memory</figcaption>
420
+ </figure>
421
+ <figure>
422
+ <img src="assets/readme_previews/ssm_legacy_replay_gt.gif" alt="Static legacy VideoSSM replay">
423
+ <figcaption>Legacy Hybrid</figcaption>
424
+ </figure>
425
+ <figure>
426
+ <img src="assets/readme_previews/ssm_blockwise_replay_gt.gif" alt="Static Block-wise SSM replay">
427
+ <figcaption>Block-wise SSM</figcaption>
428
+ </figure>
429
+ <h3 class="demo-grid__label">SpatialVID Replay</h3>
430
+ <figure>
431
+ <img src="assets/readme_previews/dyn_context_k1_replay.gif" alt="Dynamic Context K=1 replay">
432
+ <figcaption>Context K=1</figcaption>
433
+ </figure>
434
+ <figure>
435
+ <img src="assets/readme_previews/dyn_context_k5_replay.gif" alt="Dynamic Context K=5 replay">
436
+ <figcaption>Context K=5</figcaption>
437
+ </figure>
438
+ <figure>
439
+ <img src="assets/readme_previews/dyn_context_k20_replay.gif" alt="Dynamic Context K=20 replay">
440
+ <figcaption>Context K=20</figcaption>
441
+ </figure>
442
+ <figure>
443
+ <img src="assets/readme_previews/dyn_spatial_memory_replay.gif" alt="Dynamic Spatial Memory replay">
444
+ <figcaption>Spatial Memory</figcaption>
445
+ </figure>
446
+ <figure>
447
+ <img src="assets/readme_previews/dyn_ssm_legacy_replay.gif" alt="Dynamic legacy VideoSSM replay">
448
+ <figcaption>Legacy Hybrid</figcaption>
449
+ </figure>
450
+ <figure>
451
+ <img src="assets/readme_previews/dyn_ssm_blockwise_replay.gif" alt="Dynamic Block-wise SSM replay">
452
+ <figcaption>Block-wise SSM</figcaption>
453
+ </figure>
454
+ </div>
455
+ <p class="section-note" data-i18n="evidence.dynamic.note">
456
+ Dynamic previews use one randomly selected training scene replayed with the same first frame, prompt, and GT camera trajectory across all six rows.
457
+ </p>
458
+ </div>
459
+ </section>
460
+
461
+ <section id="findings" class="section section--center reveal" data-section="findings">
462
+ <div class="container">
463
+ <p class="section-kicker" data-i18n="findings.kicker">06 · Main Conclusions</p>
464
+ <h2 class="section-title" data-i18n="findings.title">Replay quality ≠ memory quality.</h2>
465
+ <p class="section-lead" data-i18n="findings.lead">
466
+ Replay metrics and return probes do not always agree — a model can look sharp on reconstruction
467
+ yet fail when the camera returns. Rankings reorder once identity under revisit is measured.
468
+ </p>
469
+ <ul class="bullet-list bullet-list--center">
470
+ <li data-i18n-html="findings.b1"><strong>Raw context</strong> — more history helps open-domain return more than replay alone.</li>
471
+ <li data-i18n-html="findings.b2"><strong>Compression</strong> — compact tokens can preserve replay while losing identity on return.</li>
472
+ <li data-i18n-html="findings.b3"><strong>Spatial vs. SSM</strong> — explicit state and block-wise SSM trade off layout carry and long-horizon stability.</li>
473
+ <li data-i18n-html="findings.b4"><strong>Takeaway</strong> — treat replay as a health check, not the final memory benchmark.</li>
474
+ </ul>
475
+ <figure class="section-figure" data-zoomable>
476
+ <img src="assets/replay_revisit_metric_alignment.png" alt="Replay vs revisit metrics" />
477
+ <figcaption data-i18n-html="findings.fig.cap">Rank shift from replay to return — replay is not the final memory score. <span class="zoom-hint">Click to expand</span></figcaption>
478
+ </figure>
479
+ </div>
480
+ </section>
481
+
482
+ <section id="updates" class="section reveal" data-section="updates">
483
+ <div class="container">
484
+ <p class="section-kicker" data-i18n="updates.kicker">07 · News &amp; Roadmap</p>
485
+ <h2 class="section-title" data-i18n="updates.title">Release notes and next steps.</h2>
486
+
487
+ <div class="updates-grid">
488
+ <div class="updates-panel">
489
+ <h3 class="updates-heading" data-i18n="updates.news">News</h3>
490
+ <ul class="news-feed">
491
+ <li class="news-item">
492
+ <time datetime="2026-06-13">2026/06/13</time>
493
+ <p data-i18n-html="updates.news0">
494
+ SpatialVID support added: dynamic training/inference recipes, 5-second first-chunk replay previews, and dynamic eval TODO.
495
+ </p>
496
+ </li>
497
+ <li class="news-item">
498
+ <time datetime="2026-06-06">2026/06/06</time>
499
+ <p data-i18n-html="updates.news1">
500
+ Echo-Memory released: paper, project page, public code, replay/revisit eval assets, and baseline checkpoints.
501
+ </p>
502
+ </li>
503
+ </ul>
504
+ </div>
505
+
506
+ <div class="updates-panel">
507
+ <h3 class="updates-heading" data-i18n="updates.roadmap">Roadmap</h3>
508
+ <div class="todo-groups">
509
+ <article class="todo-group">
510
+ <h4 data-i18n="updates.models">Models</h4>
511
+ <ul class="todo-list">
512
+ <li class="is-done">
513
+ <span class="todo-check is-done" aria-hidden="true"></span>
514
+ <span data-i18n-html="updates.rm1"><strong>Wan 2.1 1.3B</strong> backbone and training recipes</span>
515
+ </li>
516
+ <li class="is-done">
517
+ <span class="todo-check is-done" aria-hidden="true"></span>
518
+ <span data-i18n="updates.rm2">Four memory families — Context, Compression, Spatial, State-Space</span>
519
+ </li>
520
+ <li class="is-done">
521
+ <span class="todo-check is-done" aria-hidden="true"></span>
522
+ <span data-i18n-html="updates.rm3"><strong>Dynamic training pool</strong> — SpatialVID subset export &amp; settings</span>
523
+ </li>
524
+ <li class="is-done">
525
+ <span class="todo-check is-done" aria-hidden="true"></span>
526
+ <span data-i18n-html="updates.rm4"><strong>Paper checkpoints</strong> — <a href="https://huggingface.co/Echo-Team/Echo-Memory" target="_blank" rel="noopener noreferrer">Echo-Team/Echo-Memory</a></span>
527
+ </li>
528
+ <li>
529
+ <span class="todo-check" aria-hidden="true"></span>
530
+ <span data-i18n-html="updates.rm5"><strong>Wan 2.2</strong> + multi-scale <strong>5B / 14B</strong></span>
531
+ </li>
532
+ </ul>
533
+ </article>
534
+ <article class="todo-group">
535
+ <h4 data-i18n="updates.eval">Eval</h4>
536
+ <ul class="todo-list">
537
+ <li>
538
+ <span class="todo-check" aria-hidden="true"></span>
539
+ <span data-i18n-html="updates.re1"><strong>Dynamic eval</strong> beyond static replay/revisit</span>
540
+ </li>
541
+ <li>
542
+ <span class="todo-check" aria-hidden="true"></span>
543
+ <span data-i18n="updates.re2">More revisit probes and scoring presets</span>
544
+ </li>
545
+ </ul>
546
+ </article>
547
+ </div>
548
+ </div>
549
+
550
+ <div class="updates-panel community-panel">
551
+ <h3 class="updates-heading" data-i18n="community.title">Community</h3>
552
+ <p data-i18n="community.lead">Join the Echo-Memory WeChat group for release updates, checkpoint questions, and maintainer coordination.</p>
553
+ <figure class="wechat-qr">
554
+ <img
555
+ class="wechat-qr__photo"
556
+ src="assets/wechat_group_qrcode.jpg?v=20260701"
557
+ width="1166"
558
+ height="1640"
559
+ alt="Echo-Memory WeChat group QR code"
560
+ data-i18n-attr="alt:community.qr.alt"
561
+ />
562
+ <figcaption data-i18n="community.qr.caption">Echo-Memory 交流群 · scan to join (QR refreshes periodically)</figcaption>
563
+ </figure>
564
+ </div>
565
+ </div>
566
+ </div>
567
+ </section>
568
+
569
+ <section id="bibtex" class="section reveal" data-section="bibtex">
570
+ <div class="container">
571
+ <p class="section-kicker" data-i18n="bibtex.kicker">08 · Citation</p>
572
+ <h2 class="section-title" data-i18n="bibtex.title">BibTeX</h2>
573
+ <p class="section-lead" data-i18n-html="bibtex.lead">
574
+ Echo-Memory: A Controlled Study of Memory in Action World Models (June 2026).
575
+ Licensed under
576
+ <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC BY 4.0</a>.
577
+ Cite the arXiv preprint below.
578
+ </p>
579
+ <div class="cite-switcher">
580
+ <div class="cite-panel is-active">
581
+ <dl class="pub-meta">
582
+ <div class="pub-meta__row">
583
+ <dt data-i18n="bibtex.source">Source</dt>
584
+ <dd>
585
+ <a href="https://arxiv.org/abs/2606.09803" target="_blank" rel="noopener noreferrer">arXiv</a>
586
+ </dd>
587
+ </div>
588
+ <div class="pub-meta__row">
589
+ <dt data-i18n="bibtex.arxivid">arXiv ID</dt>
590
+ <dd>2606.09803</dd>
591
+ </div>
592
+ <div class="pub-meta__row">
593
+ <dt data-i18n="bibtex.pdf">PDF</dt>
594
+ <dd>
595
+ <a href="https://arxiv.org/pdf/2606.09803" target="_blank" rel="noopener noreferrer">arxiv.org/pdf/2606.09803</a>
596
+ </dd>
597
+ </div>
598
+ <div class="pub-meta__row">
599
+ <dt data-i18n="bibtex.license">License</dt>
600
+ <dd>CC BY 4.0</dd>
601
+ </div>
602
+ </dl>
603
+ <div class="bibtex-wrap">
604
+ <button type="button" class="copy-btn" data-copy-target="bibtex-arxiv-block" data-i18n="bibtex.copy">Copy</button>
605
+ <pre class="bibtex" id="bibtex-arxiv-block"><code>@article{king2026echomemory,
606
+ title={Echo-Memory: A Controlled Study of Memory in Action World Models},
607
+ author={King, Wayne and Xue, Zeyue and Bian, Yuxuan and Huang, Jie and Li, Haoran and Li, Yaowei and Su, Yaofeng and Li, Yuming and Wang, Haoyu and Zhang, Shiyi and Zhang, Songchun and Niu, Yuwei and Xu, Sihan and Zhuang, Junhao and Huang, Haoyang and Duan, Nan},
608
+ journal={arXiv preprint arXiv:2606.09803},
609
+ year={2026},
610
+ month={jun},
611
+ eprint={2606.09803},
612
+ archivePrefix={arXiv},
613
+ primaryClass={cs.CV},
614
+ url={https://arxiv.org/abs/2606.09803}
615
+ }</code></pre>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ </div>
620
+ </section>
621
+ </main>
622
+
623
+ <footer class="footer">
624
+ <div class="container footer-row">
625
+ <div>
626
+ <p class="footer-copy" data-i18n="footer.copy">© Echo Team · Joy Future Academy, JD</p>
627
+ </div>
628
+ <div class="footer-links">
629
+ <a href="https://github.com/Echo-Team-Joy-Future-Academy-JD/Echo-Memory">GitHub</a>
630
+ <a href="https://arxiv.org/abs/2606.09803" target="_blank" rel="noopener noreferrer">arXiv</a>
631
+ <a href="https://arxiv.org/pdf/2606.09803" target="_blank" rel="noopener noreferrer">PDF</a>
632
+ <a href="https://echo-team-joy-future-academy-jd.github.io/Echo-Memory/" data-i18n="footer.pp">Project Page</a>
633
+ <a href="developer.html" data-i18n="nav.developer">Developer Guide</a>
634
+ </div>
635
+ <p class="build-id" hidden aria-hidden="true">site-build-main</p>
636
+ </div>
637
+ </footer>
638
+ </div>
639
+
640
+ <script src="i18n.js?v=lang-v2"></script>
641
+ <script src="i18n-runtime.js?v=lang-v2"></script>
642
+ <script src="site.js?v=lang-v1" defer></script>
643
+ </body>
644
+ </html>
code/docs/site.js ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ "use strict";
3
+
4
+ const NAV_OFFSET = 72;
5
+ const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6
+ const SCROLL_BEHAVIOR = REDUCED_MOTION ? "auto" : "smooth";
7
+
8
+ const sectionEls = Array.from(document.querySelectorAll(".section[data-section]"));
9
+ const navLinks = Array.from(document.querySelectorAll("[data-nav]"));
10
+ const slideDots = Array.from(document.querySelectorAll(".slide-dots button[data-slide]"));
11
+ const navToggle = document.getElementById("nav-toggle");
12
+ const navMobile = document.getElementById("nav-menu-mobile");
13
+ const topNav = document.querySelector(".top-nav");
14
+ const progressBar = document.querySelector(".scroll-progress__bar");
15
+ const backTop = document.getElementById("back-top");
16
+ const lightbox = document.getElementById("lightbox");
17
+ const lightboxImg = document.getElementById("lightbox-img");
18
+ const lightboxCaption = document.getElementById("lightbox-caption");
19
+ const lightboxClose = lightbox ? lightbox.querySelector(".lightbox__close") : null;
20
+
21
+ function setActiveNav(id) {
22
+ navLinks.forEach(function (a) {
23
+ a.classList.toggle("is-active", a.getAttribute("data-nav") === id);
24
+ });
25
+ slideDots.forEach(function (btn) {
26
+ btn.classList.toggle("is-active", btn.getAttribute("data-slide") === id);
27
+ });
28
+ }
29
+
30
+ function scrollToSection(id) {
31
+ var el = document.getElementById(id);
32
+ if (!el) return;
33
+ el.scrollIntoView({ behavior: SCROLL_BEHAVIOR, block: "start" });
34
+ setActiveNav(id);
35
+ if (navMobile && !navMobile.hidden) {
36
+ navToggle.setAttribute("aria-expanded", "false");
37
+ navMobile.hidden = true;
38
+ if (topNav) topNav.classList.remove("is-open");
39
+ }
40
+ }
41
+
42
+ function sectionIndex(id) {
43
+ return sectionEls.findIndex(function (s) {
44
+ return s.getAttribute("data-section") === id;
45
+ });
46
+ }
47
+
48
+ function scrollAdjacent(delta) {
49
+ var activeId = null;
50
+ sectionEls.forEach(function (s) {
51
+ var rect = s.getBoundingClientRect();
52
+ if (rect.top <= NAV_OFFSET + 80 && rect.bottom > NAV_OFFSET + 80) {
53
+ activeId = s.getAttribute("data-section");
54
+ }
55
+ });
56
+ if (!activeId && sectionEls.length) activeId = sectionEls[0].getAttribute("data-section");
57
+ var idx = sectionIndex(activeId);
58
+ if (idx < 0) return;
59
+ var next = sectionEls[idx + delta];
60
+ if (next) scrollToSection(next.getAttribute("data-section"));
61
+ }
62
+
63
+ if (sectionEls.length) {
64
+ var observer = new IntersectionObserver(
65
+ function (entries) {
66
+ entries.forEach(function (entry) {
67
+ if (entry.isIntersecting) {
68
+ setActiveNav(entry.target.getAttribute("data-section"));
69
+ }
70
+ });
71
+ },
72
+ { root: null, rootMargin: "-" + NAV_OFFSET + "px 0px -50% 0px", threshold: 0.12 }
73
+ );
74
+ sectionEls.forEach(function (section) {
75
+ observer.observe(section);
76
+ });
77
+ }
78
+
79
+ document.querySelectorAll(".reveal").forEach(function (el) {
80
+ var revealObserver = new IntersectionObserver(
81
+ function (entries) {
82
+ entries.forEach(function (entry) {
83
+ if (entry.isIntersecting) {
84
+ entry.target.classList.add("is-visible");
85
+ revealObserver.unobserve(entry.target);
86
+ }
87
+ });
88
+ },
89
+ { root: null, rootMargin: "0px 0px -8% 0px", threshold: 0.08 }
90
+ );
91
+ if (REDUCED_MOTION) {
92
+ el.classList.add("is-visible");
93
+ } else {
94
+ revealObserver.observe(el);
95
+ }
96
+ });
97
+
98
+ function updateScrollUI() {
99
+ var scrollTop = window.scrollY || document.documentElement.scrollTop;
100
+ var docHeight = document.documentElement.scrollHeight - window.innerHeight;
101
+ var progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
102
+
103
+ if (progressBar) progressBar.style.width = progress + "%";
104
+ if (topNav) topNav.classList.toggle("is-scrolled", scrollTop > 24);
105
+ if (backTop) {
106
+ var show = scrollTop > window.innerHeight * 0.6;
107
+ backTop.hidden = !show;
108
+ backTop.classList.toggle("is-visible", show);
109
+ }
110
+ }
111
+
112
+ window.addEventListener("scroll", updateScrollUI, { passive: true });
113
+ updateScrollUI();
114
+
115
+ navLinks.forEach(function (a) {
116
+ a.addEventListener("click", function (e) {
117
+ e.preventDefault();
118
+ scrollToSection(a.getAttribute("data-nav"));
119
+ });
120
+ });
121
+
122
+ slideDots.forEach(function (btn) {
123
+ btn.addEventListener("click", function () {
124
+ scrollToSection(btn.getAttribute("data-slide"));
125
+ });
126
+ });
127
+
128
+ if (navToggle && navMobile) {
129
+ navToggle.addEventListener("click", function () {
130
+ var open = navMobile.hidden;
131
+ navMobile.hidden = !open;
132
+ navToggle.setAttribute("aria-expanded", String(open));
133
+ if (topNav) topNav.classList.toggle("is-open", open);
134
+ });
135
+ }
136
+
137
+ if (backTop) {
138
+ backTop.addEventListener("click", function () {
139
+ scrollToSection("hero");
140
+ });
141
+ }
142
+
143
+ function openLightbox(img, captionText) {
144
+ if (!lightbox || !lightboxImg) return;
145
+ lightboxImg.src = img.src;
146
+ lightboxImg.alt = img.alt || "";
147
+ if (lightboxCaption) {
148
+ lightboxCaption.textContent = captionText || "";
149
+ }
150
+ lightbox.hidden = false;
151
+ document.body.style.overflow = "hidden";
152
+ if (lightboxClose) lightboxClose.focus();
153
+ }
154
+
155
+ function closeLightbox() {
156
+ if (!lightbox) return;
157
+ lightbox.hidden = true;
158
+ document.body.style.overflow = "";
159
+ if (lightboxImg) lightboxImg.src = "";
160
+ }
161
+
162
+ document.querySelectorAll("[data-zoomable]").forEach(function (figure) {
163
+ figure.addEventListener("click", function (e) {
164
+ if (e.target.closest(".qual-chip, .copy-btn, button, a")) return;
165
+ var img = figure.querySelector("img.is-active, img[data-qual-image], img");
166
+ if (!img || !img.src) return;
167
+ var cap = figure.querySelector("figcaption");
168
+ var capText = cap ? cap.textContent.replace(/Click to expand/g, "").trim() : "";
169
+ openLightbox(img, capText);
170
+ });
171
+ });
172
+
173
+ if (lightboxClose) {
174
+ lightboxClose.addEventListener("click", closeLightbox);
175
+ }
176
+
177
+ if (lightbox) {
178
+ lightbox.addEventListener("click", function (e) {
179
+ if (e.target === lightbox) closeLightbox();
180
+ });
181
+ }
182
+
183
+ document.addEventListener("keydown", function (e) {
184
+ if (e.target.closest("input, textarea, pre, [contenteditable]")) return;
185
+
186
+ if (e.key === "Escape" && lightbox && !lightbox.hidden) {
187
+ closeLightbox();
188
+ return;
189
+ }
190
+
191
+ if (e.key === "ArrowDown" || e.key === "PageDown") {
192
+ e.preventDefault();
193
+ scrollAdjacent(1);
194
+ } else if (e.key === "ArrowUp" || e.key === "PageUp") {
195
+ e.preventDefault();
196
+ scrollAdjacent(-1);
197
+ }
198
+ });
199
+
200
+ function animateMetricValue(el, target) {
201
+ if (REDUCED_MOTION || !target || target === "—") {
202
+ el.textContent = target;
203
+ return;
204
+ }
205
+ var numeric = parseInt(String(target).replace(/,/g, ""), 10);
206
+ if (isNaN(numeric)) {
207
+ el.textContent = target;
208
+ return;
209
+ }
210
+ var start = 0;
211
+ var duration = 700;
212
+ var startTime = null;
213
+ function step(ts) {
214
+ if (!startTime) startTime = ts;
215
+ var t = Math.min((ts - startTime) / duration, 1);
216
+ var eased = 1 - Math.pow(1 - t, 3);
217
+ el.textContent = Math.round(start + (numeric - start) * eased).toLocaleString();
218
+ if (t < 1) requestAnimationFrame(step);
219
+ }
220
+ requestAnimationFrame(step);
221
+ }
222
+
223
+ function hydrateMetric(card) {
224
+ var valueEl = card.querySelector("[data-metric-value]");
225
+ var badgeUrl = card.getAttribute("data-badge-url");
226
+ if (!valueEl || !badgeUrl) return;
227
+
228
+ fetch(badgeUrl, { cache: "no-store" })
229
+ .then(function (res) {
230
+ if (!res.ok) throw new Error("Request failed");
231
+ return res.json();
232
+ })
233
+ .then(function (data) {
234
+ var val = data.value || data.message || valueEl.dataset.fallback || "—";
235
+ animateMetricValue(valueEl, val);
236
+ })
237
+ .catch(function () {
238
+ valueEl.textContent = valueEl.dataset.fallback || "—";
239
+ });
240
+ }
241
+
242
+ document.querySelectorAll("[data-badge-url]").forEach(function (card) {
243
+ var metricObserver = new IntersectionObserver(
244
+ function (entries) {
245
+ entries.forEach(function (entry) {
246
+ if (entry.isIntersecting) {
247
+ hydrateMetric(entry.target);
248
+ metricObserver.unobserve(entry.target);
249
+ }
250
+ });
251
+ },
252
+ { threshold: 0.4 }
253
+ );
254
+ metricObserver.observe(card);
255
+ });
256
+
257
+ document.querySelectorAll("[data-qual-viewer]").forEach(function (viewer) {
258
+ var image = viewer.querySelector("[data-qual-image]");
259
+ var caption = viewer.querySelector("[data-qual-caption]");
260
+ var chips = viewer.querySelectorAll(".qual-chip");
261
+
262
+ chips.forEach(function (chip) {
263
+ chip.addEventListener("click", function () {
264
+ chips.forEach(function (c) {
265
+ c.classList.remove("is-active");
266
+ c.setAttribute("aria-selected", "false");
267
+ });
268
+ chip.classList.add("is-active");
269
+ chip.setAttribute("aria-selected", "true");
270
+ if (!image) return;
271
+
272
+ var nextSrc = chip.dataset.src;
273
+ var nextAlt = chip.dataset.alt || "";
274
+ var nextCaption = chip.dataset.caption || "";
275
+
276
+ var currentPath = new URL(image.src, window.location.href).pathname;
277
+ if (currentPath.endsWith(nextSrc)) return;
278
+
279
+ image.classList.add("is-fading");
280
+ var preload = new Image();
281
+ preload.onload = function () {
282
+ image.src = nextSrc;
283
+ image.alt = nextAlt;
284
+ image.classList.remove("is-fading");
285
+ image.classList.add("is-active");
286
+ };
287
+ preload.onerror = function () {
288
+ image.classList.remove("is-fading");
289
+ };
290
+ preload.src = nextSrc;
291
+
292
+ if (caption) {
293
+ var hint = (window.EchoI18n && window.EchoI18n.t("zoom.hint")) || "Click to expand";
294
+ caption.innerHTML = nextCaption + ' <span class="zoom-hint">' + hint + "</span>";
295
+ }
296
+ });
297
+ });
298
+ });
299
+
300
+ function initCiteSwitcher() {
301
+ document.querySelectorAll("[data-cite-switcher]").forEach(function (switcher) {
302
+ var chips = switcher.querySelectorAll(".cite-chip");
303
+ var panels = switcher.querySelectorAll("[data-cite-panel]");
304
+
305
+ chips.forEach(function (chip) {
306
+ chip.addEventListener("click", function () {
307
+ var key = chip.getAttribute("data-cite");
308
+ chips.forEach(function (c) {
309
+ c.classList.remove("is-active");
310
+ c.setAttribute("aria-selected", "false");
311
+ });
312
+ chip.classList.add("is-active");
313
+ chip.setAttribute("aria-selected", "true");
314
+ panels.forEach(function (panel) {
315
+ var match = panel.getAttribute("data-cite-panel") === key;
316
+ panel.classList.toggle("is-active", match);
317
+ panel.hidden = !match;
318
+ });
319
+ });
320
+ });
321
+ });
322
+ }
323
+
324
+ initCiteSwitcher();
325
+
326
+ document.querySelectorAll("[data-copy-target]").forEach(function (btn) {
327
+ btn.addEventListener("click", function () {
328
+ var targetId = btn.getAttribute("data-copy-target");
329
+ var target = document.getElementById(targetId);
330
+ if (!target) return;
331
+ navigator.clipboard.writeText(target.textContent).then(
332
+ function () {
333
+ var copied = window.EchoI18n && window.EchoI18n.t("bibtex.copied");
334
+ var failed = window.EchoI18n && window.EchoI18n.t("bibtex.failed");
335
+ var copyLabel = window.EchoI18n && window.EchoI18n.t("bibtex.copy");
336
+ btn.textContent = copied || "Copied";
337
+ btn.classList.add("is-copied");
338
+ setTimeout(function () {
339
+ btn.textContent = copyLabel || "Copy";
340
+ btn.classList.remove("is-copied");
341
+ }, 2000);
342
+ },
343
+ function () {
344
+ btn.textContent = (window.EchoI18n && window.EchoI18n.t("bibtex.failed")) || "Failed";
345
+ }
346
+ );
347
+ });
348
+ });
349
+ })();
code/docs/style.css ADDED
@@ -0,0 +1,1848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #fffcfd;
3
+ --bg-soft: #fff5f8;
4
+ --surface: #ffffff;
5
+ --surface-2: #fbf0f4;
6
+ --text: #191218;
7
+ --text-soft: #655b63;
8
+ --border: #ead9df;
9
+ --brand: #bf5276;
10
+ --brand-2: #d97498;
11
+ --accent: #84354e;
12
+ --ring: rgba(191, 82, 118, 0.28);
13
+ --shadow: 0 14px 36px rgba(93, 45, 61, 0.08);
14
+ --shadow-lg: 0 22px 48px rgba(93, 45, 61, 0.12);
15
+ --nav-h: 72px;
16
+ --figure-max: 880px;
17
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
18
+ }
19
+
20
+ *,
21
+ *::before,
22
+ *::after {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ html {
27
+ scroll-behavior: smooth;
28
+ scroll-snap-type: y proximity;
29
+ scroll-padding-top: var(--nav-h);
30
+ }
31
+
32
+ body {
33
+ margin: 0;
34
+ font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
35
+ color: var(--text);
36
+ background-color: #fff9fb;
37
+ min-height: 100vh;
38
+ }
39
+
40
+ a {
41
+ color: inherit;
42
+ }
43
+
44
+ img {
45
+ max-width: 100%;
46
+ display: block;
47
+ }
48
+
49
+ .sr-only {
50
+ position: absolute;
51
+ width: 1px;
52
+ height: 1px;
53
+ padding: 0;
54
+ margin: -1px;
55
+ overflow: hidden;
56
+ clip: rect(0, 0, 0, 0);
57
+ border: 0;
58
+ }
59
+
60
+ .page-shell {
61
+ position: relative;
62
+ width: 100%;
63
+ overflow-x: hidden;
64
+ isolation: isolate;
65
+ }
66
+
67
+ #main-content {
68
+ width: 100%;
69
+ position: relative;
70
+ z-index: 1;
71
+ }
72
+
73
+ /* Ambient background — layered gradients + slow motion */
74
+ .ambient-bg {
75
+ position: fixed;
76
+ inset: 0;
77
+ z-index: -3;
78
+ pointer-events: none;
79
+ overflow: hidden;
80
+ background: linear-gradient(
81
+ 165deg,
82
+ #fffcfd 0%,
83
+ #fff6f9 38%,
84
+ #fff0f5 68%,
85
+ #fffbfc 100%
86
+ );
87
+ }
88
+
89
+ .ambient-orb {
90
+ position: absolute;
91
+ border-radius: 50%;
92
+ filter: blur(72px);
93
+ opacity: 0.55;
94
+ will-change: transform;
95
+ }
96
+
97
+ .ambient-orb--rose {
98
+ width: min(58vw, 520px);
99
+ height: min(58vw, 520px);
100
+ top: -12%;
101
+ left: -8%;
102
+ background: radial-gradient(
103
+ circle,
104
+ rgba(191, 82, 118, 0.42) 0%,
105
+ rgba(191, 82, 118, 0.12) 45%,
106
+ transparent 70%
107
+ );
108
+ animation: orbDriftRose 22s ease-in-out infinite;
109
+ }
110
+
111
+ .ambient-orb--blush {
112
+ width: min(50vw, 460px);
113
+ height: min(50vw, 460px);
114
+ top: 8%;
115
+ right: -10%;
116
+ background: radial-gradient(
117
+ circle,
118
+ rgba(217, 116, 152, 0.38) 0%,
119
+ rgba(255, 182, 198, 0.14) 50%,
120
+ transparent 72%
121
+ );
122
+ animation: orbDriftBlush 26s ease-in-out infinite;
123
+ }
124
+
125
+ .ambient-orb--pearl {
126
+ width: min(64vw, 580px);
127
+ height: min(64vw, 580px);
128
+ bottom: -18%;
129
+ left: 22%;
130
+ background: radial-gradient(
131
+ circle,
132
+ rgba(255, 220, 230, 0.5) 0%,
133
+ rgba(191, 82, 118, 0.1) 40%,
134
+ transparent 68%
135
+ );
136
+ animation: orbDriftPearl 30s ease-in-out infinite;
137
+ }
138
+
139
+ .ambient-shimmer {
140
+ position: absolute;
141
+ inset: -20%;
142
+ background: conic-gradient(
143
+ from 200deg at 50% 45%,
144
+ transparent 0deg,
145
+ rgba(191, 82, 118, 0.06) 55deg,
146
+ transparent 110deg,
147
+ rgba(217, 116, 152, 0.05) 200deg,
148
+ transparent 280deg,
149
+ rgba(255, 200, 215, 0.04) 330deg,
150
+ transparent 360deg
151
+ );
152
+ animation: shimmerRotate 48s linear infinite;
153
+ opacity: 0.9;
154
+ }
155
+
156
+ .page-shell::before {
157
+ content: "";
158
+ position: fixed;
159
+ inset: 0;
160
+ pointer-events: none;
161
+ opacity: 0.2;
162
+ background-image: radial-gradient(circle, rgba(191, 82, 118, 0.11) 1px, transparent 1px);
163
+ background-size: 26px 26px;
164
+ z-index: -2;
165
+ animation: gridPulse 8s ease-in-out infinite;
166
+ }
167
+
168
+ .page-shell::after {
169
+ content: "";
170
+ position: fixed;
171
+ inset: 0;
172
+ pointer-events: none;
173
+ z-index: -1;
174
+ background:
175
+ radial-gradient(ellipse 120% 80% at 50% 0%, rgba(255, 255, 255, 0.55), transparent 55%),
176
+ radial-gradient(ellipse 90% 60% at 50% 100%, rgba(191, 82, 118, 0.06), transparent 50%);
177
+ }
178
+
179
+ @keyframes orbDriftRose {
180
+ 0%,
181
+ 100% {
182
+ transform: translate(0, 0) scale(1);
183
+ }
184
+ 33% {
185
+ transform: translate(4%, 6%) scale(1.06);
186
+ }
187
+ 66% {
188
+ transform: translate(2%, 3%) scale(0.96);
189
+ }
190
+ }
191
+
192
+ @keyframes orbDriftBlush {
193
+ 0%,
194
+ 100% {
195
+ transform: translate(0, 0) scale(1);
196
+ }
197
+ 40% {
198
+ transform: translate(-5%, 4%) scale(1.08);
199
+ }
200
+ 70% {
201
+ transform: translate(-2%, 7%) scale(0.94);
202
+ }
203
+ }
204
+
205
+ @keyframes orbDriftPearl {
206
+ 0%,
207
+ 100% {
208
+ transform: translate(0, 0) scale(1);
209
+ }
210
+ 50% {
211
+ transform: translate(6%, -4%) scale(1.05);
212
+ }
213
+ }
214
+
215
+ @keyframes shimmerRotate {
216
+ from {
217
+ transform: rotate(0deg);
218
+ }
219
+ to {
220
+ transform: rotate(360deg);
221
+ }
222
+ }
223
+
224
+ @keyframes gridPulse {
225
+ 0%,
226
+ 100% {
227
+ opacity: 0.18;
228
+ }
229
+ 50% {
230
+ opacity: 0.24;
231
+ }
232
+ }
233
+
234
+ .container {
235
+ width: min(1120px, calc(100% - 2rem));
236
+ margin: 0 auto;
237
+ }
238
+
239
+ /* Scroll progress */
240
+ .scroll-progress {
241
+ position: fixed;
242
+ top: 0;
243
+ left: 0;
244
+ right: 0;
245
+ height: 3px;
246
+ z-index: 70;
247
+ pointer-events: none;
248
+ background: color-mix(in srgb, var(--border) 40%, transparent);
249
+ }
250
+
251
+ .scroll-progress__bar {
252
+ display: block;
253
+ height: 100%;
254
+ width: 0%;
255
+ background: linear-gradient(90deg, var(--accent), var(--brand), var(--brand-2));
256
+ border-radius: 0 2px 2px 0;
257
+ transition: width 0.08s linear;
258
+ box-shadow: 0 0 12px color-mix(in srgb, var(--brand) 45%, transparent);
259
+ }
260
+
261
+ /* Section scroll snap & progress dots */
262
+ .slide-dots {
263
+ position: fixed;
264
+ right: 14px;
265
+ top: 50%;
266
+ z-index: 55;
267
+ transform: translateY(-50%);
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 0.45rem;
271
+ }
272
+
273
+ .slide-dots button {
274
+ position: relative;
275
+ width: 9px;
276
+ height: 9px;
277
+ padding: 0;
278
+ border: none;
279
+ border-radius: 50%;
280
+ background: color-mix(in srgb, var(--brand) 28%, var(--border));
281
+ cursor: pointer;
282
+ transition: transform 0.25s var(--ease-out), background 0.25s ease, box-shadow 0.25s ease;
283
+ }
284
+
285
+ .slide-dots button::after {
286
+ content: attr(data-label);
287
+ position: absolute;
288
+ right: calc(100% + 10px);
289
+ top: 50%;
290
+ transform: translateY(-50%) translateX(6px);
291
+ padding: 0.28rem 0.55rem;
292
+ border-radius: 0.4rem;
293
+ background: var(--text);
294
+ color: #fff;
295
+ font-size: 0.68rem;
296
+ font-weight: 600;
297
+ white-space: nowrap;
298
+ opacity: 0;
299
+ pointer-events: none;
300
+ transition: opacity 0.2s ease, transform 0.2s var(--ease-out);
301
+ }
302
+
303
+ .slide-dots button:hover::after,
304
+ .slide-dots button:focus-visible::after {
305
+ opacity: 1;
306
+ transform: translateY(-50%) translateX(0);
307
+ }
308
+
309
+ .slide-dots button:hover {
310
+ transform: scale(1.2);
311
+ background: color-mix(in srgb, var(--brand) 55%, var(--border));
312
+ }
313
+
314
+ .slide-dots button.is-active {
315
+ transform: scale(1.35);
316
+ background: var(--brand);
317
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 25%, transparent);
318
+ }
319
+
320
+ /* Back to top */
321
+ .back-top {
322
+ position: fixed;
323
+ right: 1.1rem;
324
+ bottom: 1.25rem;
325
+ z-index: 56;
326
+ width: 2.6rem;
327
+ height: 2.6rem;
328
+ border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--border));
329
+ border-radius: 50%;
330
+ background: color-mix(in srgb, var(--surface) 88%, transparent);
331
+ backdrop-filter: blur(10px);
332
+ color: var(--accent);
333
+ cursor: pointer;
334
+ box-shadow: var(--shadow);
335
+ display: grid;
336
+ place-items: center;
337
+ transition: transform 0.25s var(--ease-out), opacity 0.25s ease, border-color 0.2s ease;
338
+ opacity: 0;
339
+ pointer-events: none;
340
+ }
341
+
342
+ .back-top.is-visible {
343
+ opacity: 1;
344
+ pointer-events: auto;
345
+ }
346
+
347
+ .back-top:hover {
348
+ transform: translateY(-3px);
349
+ border-color: var(--brand);
350
+ }
351
+
352
+ /* Reveal on scroll */
353
+ .reveal {
354
+ opacity: 0;
355
+ transform: translateY(28px);
356
+ transition: opacity 0.65s var(--ease-out), transform 0.65s var(--ease-out);
357
+ }
358
+
359
+ .reveal.is-visible {
360
+ opacity: 1;
361
+ transform: translateY(0);
362
+ }
363
+
364
+ .reveal .card,
365
+ .reveal .section-figure,
366
+ .reveal .qual-viewer {
367
+ transition-delay: 0.05s;
368
+ }
369
+
370
+ /* Nav */
371
+ .top-nav {
372
+ position: sticky;
373
+ top: 0;
374
+ z-index: 60;
375
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
376
+ background: color-mix(in srgb, var(--surface) 72%, transparent);
377
+ backdrop-filter: blur(16px) saturate(1.15);
378
+ -webkit-backdrop-filter: blur(16px) saturate(1.15);
379
+ transition: box-shadow 0.3s ease, background 0.3s ease;
380
+ }
381
+
382
+ .top-nav.is-scrolled {
383
+ box-shadow: 0 8px 24px rgba(93, 45, 61, 0.06);
384
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
385
+ }
386
+
387
+ .top-nav__inner {
388
+ min-height: var(--nav-h);
389
+ display: grid;
390
+ grid-template-columns: auto minmax(0, 1fr) auto;
391
+ align-items: center;
392
+ gap: 0.65rem;
393
+ }
394
+
395
+ .brand {
396
+ display: inline-flex;
397
+ align-items: center;
398
+ gap: 0.55rem;
399
+ text-decoration: none;
400
+ font-family: "Space Grotesk", sans-serif;
401
+ font-weight: 700;
402
+ font-size: 1.04rem;
403
+ flex-shrink: 0;
404
+ }
405
+
406
+ .brand:hover {
407
+ text-decoration: none;
408
+ }
409
+
410
+ .brand-name {
411
+ color: var(--text);
412
+ }
413
+
414
+ .brand-team {
415
+ padding: 0.2rem 0.55rem;
416
+ border-radius: 999px;
417
+ font-size: 0.68rem;
418
+ font-weight: 600;
419
+ color: var(--accent);
420
+ background: color-mix(in srgb, var(--brand) 12%, var(--surface));
421
+ border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--border));
422
+ }
423
+
424
+ .nav-links {
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ gap: 0.3rem;
429
+ flex-wrap: nowrap;
430
+ min-width: 0;
431
+ }
432
+
433
+ .nav-link {
434
+ text-decoration: none;
435
+ color: var(--text-soft);
436
+ font-size: 0.8rem;
437
+ font-weight: 500;
438
+ padding: 0.35rem 0.38rem;
439
+ white-space: nowrap;
440
+ border-radius: 0.45rem;
441
+ transition: color 0.2s ease, background 0.2s ease;
442
+ }
443
+
444
+ .nav-link:hover,
445
+ .nav-link.is-active {
446
+ color: var(--accent);
447
+ background: color-mix(in srgb, var(--brand) 10%, transparent);
448
+ text-decoration: none;
449
+ }
450
+
451
+ .nav-actions {
452
+ display: inline-flex;
453
+ align-items: center;
454
+ gap: 0.55rem;
455
+ flex-shrink: 0;
456
+ }
457
+
458
+ .action-btn {
459
+ border: 1px solid var(--border);
460
+ background: var(--surface-2);
461
+ color: var(--text);
462
+ border-radius: 0.6rem;
463
+ min-height: 2.2rem;
464
+ padding: 0.35rem 0.7rem;
465
+ font-size: 0.82rem;
466
+ font-weight: 600;
467
+ text-decoration: none;
468
+ cursor: pointer;
469
+ transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
470
+ }
471
+
472
+ .action-btn:hover {
473
+ transform: translateY(-1px);
474
+ border-color: color-mix(in srgb, var(--brand) 58%, var(--border));
475
+ text-decoration: none;
476
+ }
477
+
478
+ .nav-toggle {
479
+ display: none;
480
+ flex-direction: column;
481
+ gap: 5px;
482
+ padding: 8px;
483
+ border: 1px solid var(--border);
484
+ border-radius: 0.5rem;
485
+ background: var(--surface);
486
+ cursor: pointer;
487
+ }
488
+
489
+ .nav-toggle span {
490
+ display: block;
491
+ width: 20px;
492
+ height: 2px;
493
+ background: var(--text);
494
+ border-radius: 2px;
495
+ }
496
+
497
+ .nav-mobile {
498
+ display: none;
499
+ flex-direction: column;
500
+ gap: 0.25rem;
501
+ padding: 0 0 1rem;
502
+ border-top: 1px solid var(--border);
503
+ background: color-mix(in srgb, var(--surface) 95%, transparent);
504
+ }
505
+
506
+ .top-nav.is-open .nav-mobile {
507
+ display: flex;
508
+ }
509
+
510
+ .nav-mobile .nav-link {
511
+ padding: 0.55rem 0.65rem;
512
+ }
513
+
514
+ /* Sections (starVLA-style: normal flow, container max-width) */
515
+ .section {
516
+ position: relative;
517
+ padding: 4.5rem 0;
518
+ scroll-margin-top: var(--nav-h);
519
+ scroll-snap-align: start;
520
+ }
521
+
522
+ #main-content > .section + .section::before {
523
+ content: "";
524
+ position: absolute;
525
+ top: 0;
526
+ left: 50%;
527
+ transform: translateX(-50%);
528
+ width: min(1120px, calc(100% - 2rem));
529
+ height: 1px;
530
+ background: linear-gradient(
531
+ 90deg,
532
+ transparent 0%,
533
+ color-mix(in srgb, var(--brand) 18%, var(--border)) 18%,
534
+ color-mix(in srgb, var(--brand) 18%, var(--border)) 82%,
535
+ transparent 100%
536
+ );
537
+ opacity: 0.85;
538
+ }
539
+
540
+ #main-content > .section:nth-of-type(even) {
541
+ background: linear-gradient(
542
+ 180deg,
543
+ color-mix(in srgb, var(--surface) 55%, transparent) 0%,
544
+ color-mix(in srgb, var(--surface-2) 28%, transparent) 100%
545
+ );
546
+ backdrop-filter: blur(2px);
547
+ }
548
+
549
+ .section--center .container {
550
+ text-align: center;
551
+ }
552
+
553
+ .section--center .section-lead {
554
+ margin-inline: auto;
555
+ }
556
+
557
+ .section--center .feature-grid {
558
+ text-align: left;
559
+ }
560
+
561
+ .section--center .bullet-list {
562
+ display: inline-block;
563
+ text-align: left;
564
+ }
565
+
566
+ .section--center .qual-viewer,
567
+ .section--center .section-figure {
568
+ margin-inline: auto;
569
+ }
570
+
571
+ .section--compact {
572
+ padding-top: 3.5rem;
573
+ padding-bottom: 3.5rem;
574
+ }
575
+
576
+
577
+ .section-kicker {
578
+ margin: 0 0 0.5rem;
579
+ font-size: 0.72rem;
580
+ font-weight: 600;
581
+ letter-spacing: 0.1em;
582
+ text-transform: uppercase;
583
+ color: var(--brand);
584
+ }
585
+
586
+ .section-title {
587
+ margin: 0;
588
+ font-family: "Space Grotesk", sans-serif;
589
+ font-size: clamp(1.6rem, 4vw, 2.35rem);
590
+ line-height: 1.1;
591
+ letter-spacing: -0.02em;
592
+ }
593
+
594
+ .section-lead {
595
+ margin-top: 0.8rem;
596
+ color: var(--text-soft);
597
+ line-height: 1.7;
598
+ max-width: 70ch;
599
+ }
600
+
601
+ /* Hero */
602
+ .hero {
603
+ display: grid;
604
+ grid-template-columns: 1.08fr 0.92fr;
605
+ gap: 2.2rem;
606
+ align-items: center;
607
+ }
608
+
609
+ .hero--single {
610
+ grid-template-columns: 1fr;
611
+ }
612
+
613
+ .hero-badge {
614
+ display: inline-flex;
615
+ align-items: center;
616
+ padding: 0.35rem 0.72rem;
617
+ border-radius: 999px;
618
+ border: 1px solid color-mix(in srgb, var(--brand) 58%, transparent);
619
+ background: color-mix(in srgb, var(--brand) 10%, var(--surface));
620
+ font-size: 0.74rem;
621
+ font-weight: 600;
622
+ letter-spacing: 0.04em;
623
+ text-transform: uppercase;
624
+ color: color-mix(in srgb, var(--brand) 85%, var(--text));
625
+ }
626
+
627
+ .hero-title {
628
+ margin-top: 1.15rem;
629
+ margin-bottom: 0.95rem;
630
+ font-family: "Space Grotesk", sans-serif;
631
+ font-size: clamp(2rem, 5vw, 3.3rem);
632
+ line-height: 1.06;
633
+ letter-spacing: -0.02em;
634
+ }
635
+
636
+ .hero-highlight {
637
+ background: linear-gradient(120deg, var(--accent), var(--brand), var(--brand-2));
638
+ -webkit-background-clip: text;
639
+ background-clip: text;
640
+ -webkit-text-fill-color: transparent;
641
+ }
642
+
643
+ .hero-subtitle {
644
+ margin: 0;
645
+ color: var(--text-soft);
646
+ font-size: 1.07rem;
647
+ line-height: 1.7;
648
+ max-width: 52ch;
649
+ }
650
+
651
+ .hero-cta-group {
652
+ margin-top: 1.45rem;
653
+ display: flex;
654
+ flex-wrap: wrap;
655
+ gap: 0.75rem;
656
+ }
657
+
658
+ .cta {
659
+ text-decoration: none;
660
+ display: inline-flex;
661
+ align-items: center;
662
+ justify-content: center;
663
+ gap: 0.45rem;
664
+ border-radius: 0.8rem;
665
+ border: 1px solid transparent;
666
+ min-height: 2.8rem;
667
+ padding: 0.55rem 1rem;
668
+ font-weight: 700;
669
+ font-size: 0.94rem;
670
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
671
+ }
672
+
673
+ .cta:hover {
674
+ transform: translateY(-1px);
675
+ text-decoration: none;
676
+ }
677
+
678
+ .cta--primary {
679
+ background: linear-gradient(128deg, var(--brand), var(--brand-2));
680
+ color: #fff;
681
+ box-shadow: 0 16px 30px color-mix(in srgb, var(--brand) 34%, transparent);
682
+ }
683
+
684
+ .cta--primary:hover {
685
+ box-shadow: 0 20px 36px color-mix(in srgb, var(--brand) 42%, transparent);
686
+ }
687
+
688
+ .cta--secondary {
689
+ background: var(--surface);
690
+ border-color: var(--border);
691
+ color: var(--accent);
692
+ }
693
+
694
+ .metrics-grid {
695
+ margin-top: 1.6rem;
696
+ display: grid;
697
+ gap: 0.85rem;
698
+ grid-template-columns: repeat(3, minmax(0, 1fr));
699
+ max-width: 640px;
700
+ }
701
+
702
+ .metrics-grid--duo {
703
+ grid-template-columns: repeat(2, minmax(0, 1fr));
704
+ max-width: 420px;
705
+ }
706
+
707
+ .hero-note {
708
+ margin: 1.25rem 0 0;
709
+ max-width: 52ch;
710
+ font-size: 0.92rem;
711
+ line-height: 1.65;
712
+ color: var(--text-soft);
713
+ }
714
+
715
+ .section-lead a {
716
+ color: var(--accent);
717
+ font-weight: 600;
718
+ text-decoration: underline;
719
+ text-underline-offset: 2px;
720
+ }
721
+
722
+ .section-lead a:hover {
723
+ color: var(--brand);
724
+ }
725
+
726
+ .metric-card {
727
+ display: block;
728
+ padding: 0.9rem 1rem;
729
+ border: 1px solid var(--border);
730
+ border-radius: 0.95rem;
731
+ background: var(--surface);
732
+ box-shadow: var(--shadow);
733
+ text-decoration: none;
734
+ transition: transform 0.2s ease, border-color 0.2s ease;
735
+ }
736
+
737
+ .metric-card:hover {
738
+ transform: translateY(-2px);
739
+ border-color: color-mix(in srgb, var(--brand) 45%, var(--border));
740
+ text-decoration: none;
741
+ }
742
+
743
+ .metric-label {
744
+ display: block;
745
+ color: var(--text-soft);
746
+ font-size: 0.8rem;
747
+ letter-spacing: 0.02em;
748
+ }
749
+
750
+ .metric-value {
751
+ margin-top: 0.25rem;
752
+ display: block;
753
+ font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
754
+ font-size: 1.45rem;
755
+ font-weight: 700;
756
+ color: var(--text);
757
+ }
758
+
759
+ .authors-fold {
760
+ margin-top: 1.5rem;
761
+ max-width: 52ch;
762
+ font-size: 0.875rem;
763
+ color: var(--text-soft);
764
+ }
765
+
766
+ .authors-fold summary {
767
+ cursor: pointer;
768
+ font-weight: 600;
769
+ color: var(--accent);
770
+ list-style: none;
771
+ }
772
+
773
+ .authors-fold summary::-webkit-details-marker {
774
+ display: none;
775
+ }
776
+
777
+ .authors {
778
+ margin: 0.75rem 0 0.35rem;
779
+ }
780
+
781
+ .affiliations {
782
+ margin: 0;
783
+ font-size: 0.82rem;
784
+ }
785
+
786
+ /* Cards & figures */
787
+ .feature-grid {
788
+ margin-top: 1.35rem;
789
+ display: grid;
790
+ grid-template-columns: repeat(4, minmax(0, 1fr));
791
+ gap: 0.95rem;
792
+ }
793
+
794
+ .feature-grid--three {
795
+ grid-template-columns: repeat(3, minmax(0, 1fr));
796
+ }
797
+
798
+ .card {
799
+ border: 1px solid var(--border);
800
+ background: var(--surface);
801
+ border-radius: 1rem;
802
+ padding: 1.2rem;
803
+ box-shadow: var(--shadow);
804
+ transition: transform 0.28s var(--ease-out), border-color 0.28s ease, box-shadow 0.28s ease;
805
+ }
806
+
807
+ .card--accent {
808
+ position: relative;
809
+ overflow: hidden;
810
+ }
811
+
812
+ .card--accent::before {
813
+ content: "";
814
+ position: absolute;
815
+ top: 0;
816
+ left: 0;
817
+ right: 0;
818
+ height: 3px;
819
+ background: var(--card-accent, var(--brand));
820
+ opacity: 0.85;
821
+ transition: height 0.25s ease;
822
+ }
823
+
824
+ .card--accent[data-accent="context"] {
825
+ --card-accent: #c45a7a;
826
+ }
827
+
828
+ .card--accent[data-accent="compression"] {
829
+ --card-accent: #a84d8f;
830
+ }
831
+
832
+ .card--accent[data-accent="spatial"] {
833
+ --card-accent: #84354e;
834
+ }
835
+
836
+ .card--accent[data-accent="ssm"] {
837
+ --card-accent: #d97498;
838
+ }
839
+
840
+ .card--accent:hover {
841
+ transform: translateY(-4px);
842
+ border-color: color-mix(in srgb, var(--card-accent) 45%, var(--border));
843
+ box-shadow: 0 18px 40px color-mix(in srgb, var(--card-accent) 14%, transparent);
844
+ }
845
+
846
+ .card--accent:hover::before {
847
+ height: 4px;
848
+ }
849
+
850
+ .card-icon {
851
+ width: 2.1rem;
852
+ height: 2.1rem;
853
+ border-radius: 0.55rem;
854
+ display: grid;
855
+ place-items: center;
856
+ background: color-mix(in srgb, var(--card-accent, var(--brand)) 12%, var(--surface));
857
+ color: var(--card-accent, var(--brand));
858
+ }
859
+
860
+ .card-icon svg {
861
+ width: 1.15rem;
862
+ height: 1.15rem;
863
+ }
864
+
865
+ .card h3 {
866
+ margin: 0.75rem 0 0.55rem;
867
+ font-family: "Space Grotesk", sans-serif;
868
+ font-size: 1.1rem;
869
+ }
870
+
871
+ .card p {
872
+ margin: 0;
873
+ color: var(--text-soft);
874
+ line-height: 1.65;
875
+ font-size: 0.92rem;
876
+ }
877
+
878
+ .bullet-list {
879
+ margin: 1rem 0 1.25rem;
880
+ padding-left: 1.25rem;
881
+ color: var(--text-soft);
882
+ line-height: 1.65;
883
+ }
884
+
885
+ .bullet-list li {
886
+ margin-bottom: 0.4rem;
887
+ }
888
+
889
+ .section-figure {
890
+ margin: 1.5rem auto 0;
891
+ text-align: center;
892
+ max-width: var(--figure-max);
893
+ }
894
+
895
+ [data-zoomable] {
896
+ cursor: zoom-in;
897
+ }
898
+
899
+ .section-figure img {
900
+ width: 100%;
901
+ margin: 0 auto;
902
+ border: 1px solid var(--border);
903
+ border-radius: 1rem;
904
+ background: var(--surface);
905
+ box-shadow: var(--shadow);
906
+ transition: transform 0.35s var(--ease-out), box-shadow 0.35s ease;
907
+ }
908
+
909
+ [data-zoomable]:hover img {
910
+ transform: scale(1.012);
911
+ box-shadow: var(--shadow-lg);
912
+ }
913
+
914
+ .section-figure figcaption {
915
+ margin-top: 0.65rem;
916
+ font-size: 0.84rem;
917
+ color: var(--text-soft);
918
+ }
919
+
920
+ .zoom-hint {
921
+ display: inline-block;
922
+ margin-left: 0.35rem;
923
+ font-size: 0.75rem;
924
+ color: color-mix(in srgb, var(--brand) 70%, var(--text-soft));
925
+ opacity: 0;
926
+ transition: opacity 0.2s ease;
927
+ }
928
+
929
+ [data-zoomable]:hover .zoom-hint {
930
+ opacity: 1;
931
+ }
932
+
933
+ /* Qual viewer */
934
+ .qual-viewer {
935
+ margin-top: 1.25rem;
936
+ max-width: var(--figure-max);
937
+ margin-inline: auto;
938
+ }
939
+
940
+ .qual-stage {
941
+ margin: 0 0 0.85rem;
942
+ padding: 0.85rem;
943
+ border: 1px solid var(--border);
944
+ border-radius: 1rem;
945
+ background: var(--surface);
946
+ box-shadow: var(--shadow);
947
+ }
948
+
949
+ .qual-stage__media {
950
+ position: relative;
951
+ overflow: hidden;
952
+ border-radius: 0.65rem;
953
+ aspect-ratio: 16 / 9;
954
+ background: color-mix(in srgb, var(--surface-2) 60%, var(--surface));
955
+ }
956
+
957
+ .qual-stage img {
958
+ width: 100%;
959
+ height: 100%;
960
+ object-fit: contain;
961
+ border-radius: 0.65rem;
962
+ transition: opacity 0.35s ease, transform 0.35s var(--ease-out);
963
+ }
964
+
965
+ .qual-stage img.is-fading {
966
+ opacity: 0;
967
+ transform: scale(0.985);
968
+ }
969
+
970
+ .qual-stage figcaption {
971
+ margin-top: 0.55rem;
972
+ font-size: 0.84rem;
973
+ color: var(--text-soft);
974
+ text-align: center;
975
+ }
976
+
977
+ .qual-chips {
978
+ display: flex;
979
+ flex-wrap: wrap;
980
+ justify-content: center;
981
+ gap: 0.5rem;
982
+ }
983
+
984
+ .qual-chip {
985
+ border: 1px solid var(--border);
986
+ border-radius: 999px;
987
+ padding: 0.45rem 0.85rem;
988
+ background: var(--surface);
989
+ font-size: 0.82rem;
990
+ font-weight: 600;
991
+ color: var(--text-soft);
992
+ cursor: pointer;
993
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
994
+ }
995
+
996
+ .qual-chip.is-active,
997
+ .qual-chip:hover {
998
+ color: #fff;
999
+ background: var(--brand);
1000
+ border-color: var(--brand);
1001
+ }
1002
+
1003
+ .qual-chip {
1004
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s var(--ease-out);
1005
+ }
1006
+
1007
+ .qual-chip:active {
1008
+ transform: scale(0.97);
1009
+ }
1010
+
1011
+ .demo-grid {
1012
+ margin: 1.3rem auto 0;
1013
+ max-width: 980px;
1014
+ display: grid;
1015
+ grid-template-columns: repeat(6, minmax(0, 1fr));
1016
+ gap: 0.75rem;
1017
+ }
1018
+
1019
+ .demo-grid__label {
1020
+ grid-column: 1 / -1;
1021
+ margin: 0.35rem 0 -0.25rem;
1022
+ font-family: "Space Grotesk", sans-serif;
1023
+ font-size: 0.95rem;
1024
+ color: var(--text);
1025
+ text-align: left;
1026
+ }
1027
+
1028
+ .demo-grid figure {
1029
+ margin: 0;
1030
+ padding: 0.55rem;
1031
+ border: 1px solid var(--border);
1032
+ border-radius: 0.9rem;
1033
+ background: var(--surface);
1034
+ box-shadow: var(--shadow);
1035
+ }
1036
+
1037
+ .demo-grid img {
1038
+ width: 100%;
1039
+ aspect-ratio: 16 / 9;
1040
+ object-fit: cover;
1041
+ border-radius: 0.55rem;
1042
+ background: var(--surface-2);
1043
+ }
1044
+
1045
+ .demo-grid figcaption {
1046
+ margin-top: 0.45rem;
1047
+ text-align: center;
1048
+ color: var(--text-soft);
1049
+ font-size: 0.76rem;
1050
+ font-weight: 700;
1051
+ }
1052
+
1053
+ /* Lightbox */
1054
+ .lightbox {
1055
+ position: fixed;
1056
+ inset: 0;
1057
+ z-index: 100;
1058
+ display: grid;
1059
+ place-items: center;
1060
+ padding: 1.5rem;
1061
+ background: rgba(25, 18, 24, 0.72);
1062
+ backdrop-filter: blur(8px);
1063
+ animation: lightboxIn 0.25s ease;
1064
+ }
1065
+
1066
+ .lightbox[hidden] {
1067
+ display: none;
1068
+ }
1069
+
1070
+ .lightbox__close {
1071
+ position: absolute;
1072
+ top: 1rem;
1073
+ right: 1rem;
1074
+ width: 2.5rem;
1075
+ height: 2.5rem;
1076
+ border: none;
1077
+ border-radius: 50%;
1078
+ background: rgba(255, 255, 255, 0.12);
1079
+ color: #fff;
1080
+ font-size: 1.5rem;
1081
+ line-height: 1;
1082
+ cursor: pointer;
1083
+ transition: background 0.2s ease;
1084
+ }
1085
+
1086
+ .lightbox__close:hover {
1087
+ background: rgba(255, 255, 255, 0.22);
1088
+ }
1089
+
1090
+ .lightbox__inner {
1091
+ margin: 0;
1092
+ max-width: min(1100px, 96vw);
1093
+ max-height: 90vh;
1094
+ }
1095
+
1096
+ .lightbox__inner img {
1097
+ max-width: 100%;
1098
+ max-height: calc(90vh - 3rem);
1099
+ margin: 0 auto;
1100
+ border-radius: 0.75rem;
1101
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
1102
+ }
1103
+
1104
+ .lightbox__inner figcaption {
1105
+ margin-top: 0.75rem;
1106
+ text-align: center;
1107
+ color: rgba(255, 255, 255, 0.82);
1108
+ font-size: 0.9rem;
1109
+ }
1110
+
1111
+ @keyframes lightboxIn {
1112
+ from {
1113
+ opacity: 0;
1114
+ }
1115
+ to {
1116
+ opacity: 1;
1117
+ }
1118
+ }
1119
+
1120
+ /* News & roadmap */
1121
+ .updates-grid {
1122
+ margin-top: 1.5rem;
1123
+ display: grid;
1124
+ grid-template-columns: 1fr 1fr minmax(220px, 0.85fr);
1125
+ gap: 1.25rem;
1126
+ align-items: start;
1127
+ }
1128
+
1129
+ .community-panel .wechat-qr {
1130
+ margin: 0.75rem 0 0;
1131
+ }
1132
+
1133
+ .community-panel {
1134
+ overflow: visible;
1135
+ }
1136
+
1137
+ .wechat-qr {
1138
+ margin: 0.75rem auto 0;
1139
+ padding: 0;
1140
+ text-align: center;
1141
+ overflow: visible;
1142
+ }
1143
+
1144
+ /* Portrait WeChat screenshot: set display width only; height follows intrinsic ratio (1166×1640). */
1145
+ .wechat-qr__photo {
1146
+ display: block;
1147
+ width: 220px;
1148
+ max-width: 100%;
1149
+ height: auto;
1150
+ margin: 0 auto;
1151
+ border-radius: 0.75rem;
1152
+ border: 1px solid var(--border);
1153
+ box-shadow: var(--shadow);
1154
+ }
1155
+
1156
+ .developer-page .wechat-qr {
1157
+ margin: 1rem auto 0;
1158
+ overflow: visible;
1159
+ }
1160
+
1161
+ .developer-page .wechat-qr__photo {
1162
+ width: 260px;
1163
+ }
1164
+
1165
+ .wechat-qr figcaption {
1166
+ margin-top: 0.55rem;
1167
+ font-size: 0.82rem;
1168
+ color: var(--text-soft);
1169
+ }
1170
+
1171
+ .lang-toggle {
1172
+ border: 1px solid var(--border);
1173
+ background: var(--surface);
1174
+ color: var(--accent);
1175
+ border-radius: 999px;
1176
+ padding: 0.35rem 0.75rem;
1177
+ font-size: 0.78rem;
1178
+ font-weight: 600;
1179
+ cursor: pointer;
1180
+ font-family: inherit;
1181
+ transition: background 0.2s ease, border-color 0.2s ease;
1182
+ }
1183
+
1184
+ .lang-toggle:hover {
1185
+ background: var(--surface-2);
1186
+ border-color: var(--brand-2);
1187
+ }
1188
+
1189
+ .action-btn--ghost {
1190
+ background: transparent;
1191
+ border: 1px solid var(--border);
1192
+ color: var(--accent);
1193
+ }
1194
+
1195
+ .action-btn--ghost:hover {
1196
+ background: var(--surface-2);
1197
+ }
1198
+
1199
+ .page-shell--doc {
1200
+ min-height: 100vh;
1201
+ }
1202
+
1203
+ .top-nav--doc {
1204
+ position: sticky;
1205
+ top: 0;
1206
+ z-index: 20;
1207
+ background: rgba(255, 252, 253, 0.92);
1208
+ backdrop-filter: blur(10px);
1209
+ border-bottom: 1px solid var(--border);
1210
+ }
1211
+
1212
+ .dev-main {
1213
+ padding: 2rem 1rem 3rem;
1214
+ max-width: 52rem;
1215
+ min-width: 0;
1216
+ box-sizing: border-box;
1217
+ }
1218
+
1219
+ .top-nav__inner--doc {
1220
+ min-width: 0;
1221
+ }
1222
+
1223
+ .action-btn--compact {
1224
+ white-space: nowrap;
1225
+ max-width: min(100%, 14rem);
1226
+ overflow: hidden;
1227
+ text-overflow: ellipsis;
1228
+ }
1229
+
1230
+ @media (max-width: 640px) {
1231
+ .top-nav__inner--doc {
1232
+ gap: 0.45rem;
1233
+ }
1234
+
1235
+ .action-btn--compact {
1236
+ max-width: 9.5rem;
1237
+ font-size: 0.74rem;
1238
+ padding: 0.35rem 0.5rem;
1239
+ }
1240
+
1241
+ .lang-toggle {
1242
+ font-size: 0.72rem;
1243
+ padding: 0.3rem 0.55rem;
1244
+ }
1245
+ }
1246
+
1247
+ .dev-section {
1248
+ margin-top: 1rem;
1249
+ padding: 1.15rem 1.25rem;
1250
+ overflow: visible;
1251
+ min-width: 0;
1252
+ }
1253
+
1254
+ .dev-skills-title {
1255
+ margin: 1rem 0 0.5rem;
1256
+ font-weight: 600;
1257
+ font-size: 0.92rem;
1258
+ color: var(--accent);
1259
+ }
1260
+
1261
+ .dev-section--accent {
1262
+ border-color: color-mix(in srgb, var(--brand) 35%, var(--border));
1263
+ background: color-mix(in srgb, var(--brand) 4%, var(--surface));
1264
+ }
1265
+
1266
+ .dev-table-wrap {
1267
+ overflow-x: auto;
1268
+ max-width: 100%;
1269
+ -webkit-overflow-scrolling: touch;
1270
+ margin-top: 0.5rem;
1271
+ }
1272
+
1273
+ .dev-table {
1274
+ width: 100%;
1275
+ min-width: 28rem;
1276
+ border-collapse: collapse;
1277
+ font-size: 0.84rem;
1278
+ }
1279
+
1280
+ .dev-table th,
1281
+ .dev-table td {
1282
+ border: 1px solid var(--border);
1283
+ padding: 0.45rem 0.55rem;
1284
+ text-align: left;
1285
+ vertical-align: top;
1286
+ word-break: break-word;
1287
+ }
1288
+
1289
+ .dev-table th {
1290
+ background: var(--surface-2);
1291
+ color: var(--accent);
1292
+ font-weight: 600;
1293
+ }
1294
+
1295
+ .dev-table code {
1296
+ font-size: 0.82em;
1297
+ word-break: break-all;
1298
+ }
1299
+
1300
+ .dev-section .code-block {
1301
+ max-width: 100%;
1302
+ box-sizing: border-box;
1303
+ }
1304
+
1305
+ .code-block--prompt {
1306
+ font-size: 0.78rem;
1307
+ line-height: 1.55;
1308
+ white-space: pre-wrap;
1309
+ overflow-wrap: anywhere;
1310
+ word-break: break-word;
1311
+ }
1312
+
1313
+ .dev-title {
1314
+ font-family: "Space Grotesk", sans-serif;
1315
+ font-size: clamp(1.75rem, 4vw, 2.35rem);
1316
+ margin: 0 0 0.75rem;
1317
+ color: var(--accent);
1318
+ }
1319
+
1320
+ .dev-section h2 {
1321
+ margin: 0 0 0.65rem;
1322
+ font-family: "Space Grotesk", sans-serif;
1323
+ font-size: 1.05rem;
1324
+ color: var(--accent);
1325
+ }
1326
+
1327
+ .dev-section p,
1328
+ .dev-section li {
1329
+ color: var(--text-soft);
1330
+ line-height: 1.6;
1331
+ }
1332
+
1333
+ .dev-repo-link {
1334
+ margin-top: 1.5rem;
1335
+ font-size: 0.9rem;
1336
+ color: var(--text-soft);
1337
+ }
1338
+
1339
+ .updates-panel {
1340
+ border: 1px solid var(--border);
1341
+ border-radius: 1rem;
1342
+ background: var(--surface);
1343
+ box-shadow: var(--shadow);
1344
+ padding: 1.2rem 1.25rem;
1345
+ }
1346
+
1347
+ .updates-heading {
1348
+ margin: 0 0 0.85rem;
1349
+ font-family: "Space Grotesk", sans-serif;
1350
+ font-size: 1.05rem;
1351
+ color: var(--accent);
1352
+ }
1353
+
1354
+ .news-feed {
1355
+ list-style: none;
1356
+ margin: 0;
1357
+ padding: 0;
1358
+ display: grid;
1359
+ gap: 0.9rem;
1360
+ }
1361
+
1362
+ .news-item {
1363
+ display: grid;
1364
+ grid-template-columns: 5.5rem 1fr;
1365
+ gap: 0.75rem;
1366
+ padding-left: 0.65rem;
1367
+ border-left: 2px solid color-mix(in srgb, var(--brand) 35%, var(--border));
1368
+ }
1369
+
1370
+ .news-item time {
1371
+ font-size: 0.76rem;
1372
+ font-weight: 600;
1373
+ letter-spacing: 0.02em;
1374
+ color: var(--brand);
1375
+ white-space: nowrap;
1376
+ }
1377
+
1378
+ .news-item p {
1379
+ margin: 0;
1380
+ font-size: 0.9rem;
1381
+ line-height: 1.6;
1382
+ color: var(--text-soft);
1383
+ }
1384
+
1385
+ .news-item a {
1386
+ color: var(--accent);
1387
+ font-weight: 600;
1388
+ text-decoration: underline;
1389
+ text-underline-offset: 2px;
1390
+ }
1391
+
1392
+ .news-item code {
1393
+ font-family: "JetBrains Mono", monospace;
1394
+ font-size: 0.78rem;
1395
+ padding: 0.1rem 0.35rem;
1396
+ border-radius: 0.35rem;
1397
+ background: color-mix(in srgb, var(--brand) 8%, var(--surface-2));
1398
+ }
1399
+
1400
+ .todo-groups {
1401
+ display: grid;
1402
+ gap: 0.85rem;
1403
+ }
1404
+
1405
+ .todo-group h4 {
1406
+ margin: 0 0 0.45rem;
1407
+ font-size: 0.82rem;
1408
+ font-weight: 700;
1409
+ letter-spacing: 0.04em;
1410
+ text-transform: uppercase;
1411
+ color: var(--text-soft);
1412
+ }
1413
+
1414
+ .todo-list {
1415
+ list-style: none;
1416
+ margin: 0;
1417
+ padding: 0;
1418
+ display: grid;
1419
+ gap: 0.4rem;
1420
+ }
1421
+
1422
+ .todo-list li {
1423
+ display: flex;
1424
+ align-items: flex-start;
1425
+ gap: 0.55rem;
1426
+ font-size: 0.88rem;
1427
+ line-height: 1.55;
1428
+ color: var(--text-soft);
1429
+ }
1430
+
1431
+ .todo-check {
1432
+ flex-shrink: 0;
1433
+ width: 0.95rem;
1434
+ height: 0.95rem;
1435
+ margin-top: 0.2rem;
1436
+ border: 1.5px solid color-mix(in srgb, var(--brand) 55%, var(--border));
1437
+ border-radius: 0.22rem;
1438
+ background: color-mix(in srgb, var(--brand) 6%, var(--surface));
1439
+ }
1440
+
1441
+ .todo-list li.is-done {
1442
+ color: color-mix(in srgb, var(--text) 72%, var(--text-soft));
1443
+ }
1444
+
1445
+ .todo-check.is-done {
1446
+ border-color: var(--brand);
1447
+ background: var(--brand);
1448
+ position: relative;
1449
+ }
1450
+
1451
+ .todo-check.is-done::after {
1452
+ content: "";
1453
+ position: absolute;
1454
+ left: 0.18rem;
1455
+ top: 0.12rem;
1456
+ width: 0.35rem;
1457
+ height: 0.55rem;
1458
+ border: solid #fff;
1459
+ border-width: 0 1.5px 1.5px 0;
1460
+ transform: rotate(45deg);
1461
+ }
1462
+
1463
+ /* BibTeX */
1464
+ .pub-meta {
1465
+ display: grid;
1466
+ gap: 0.55rem;
1467
+ margin: 1.25rem 0 1.75rem;
1468
+ padding: 1rem 1.1rem;
1469
+ border: 1px solid var(--border);
1470
+ border-radius: 1rem;
1471
+ background: color-mix(in srgb, var(--surface) 82%, transparent);
1472
+ box-shadow: var(--shadow);
1473
+ max-width: 36rem;
1474
+ }
1475
+
1476
+ .pub-meta__row {
1477
+ display: grid;
1478
+ grid-template-columns: 7.5rem 1fr;
1479
+ gap: 0.75rem;
1480
+ align-items: baseline;
1481
+ font-size: 0.9rem;
1482
+ }
1483
+
1484
+ .pub-meta dt {
1485
+ margin: 0;
1486
+ font-weight: 600;
1487
+ color: var(--text-soft);
1488
+ }
1489
+
1490
+ .pub-meta dd {
1491
+ margin: 0;
1492
+ color: var(--text);
1493
+ }
1494
+
1495
+ .pub-meta a {
1496
+ color: var(--accent);
1497
+ font-weight: 600;
1498
+ text-decoration: underline;
1499
+ text-underline-offset: 2px;
1500
+ }
1501
+
1502
+ .bibtex-heading {
1503
+ margin: 1.5rem 0 0.65rem;
1504
+ font-family: "Space Grotesk", sans-serif;
1505
+ font-size: 1rem;
1506
+ font-weight: 600;
1507
+ color: var(--text);
1508
+ }
1509
+
1510
+ .cite-switcher {
1511
+ margin-top: 1.25rem;
1512
+ max-width: 52rem;
1513
+ }
1514
+
1515
+ .cite-chips {
1516
+ display: flex;
1517
+ flex-wrap: wrap;
1518
+ gap: 0.5rem;
1519
+ margin-bottom: 1rem;
1520
+ }
1521
+
1522
+ .cite-chip {
1523
+ border: 1px solid var(--border);
1524
+ border-radius: 999px;
1525
+ padding: 0.45rem 0.9rem;
1526
+ background: var(--surface);
1527
+ font-size: 0.82rem;
1528
+ font-weight: 600;
1529
+ color: var(--text-soft);
1530
+ cursor: pointer;
1531
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
1532
+ }
1533
+
1534
+ .cite-chip.is-active,
1535
+ .cite-chip:hover {
1536
+ color: #fff;
1537
+ background: var(--brand);
1538
+ border-color: var(--brand);
1539
+ }
1540
+
1541
+ .cite-panel {
1542
+ animation: citeFadeIn 0.25s ease;
1543
+ }
1544
+
1545
+ @keyframes citeFadeIn {
1546
+ from {
1547
+ opacity: 0;
1548
+ transform: translateY(6px);
1549
+ }
1550
+ to {
1551
+ opacity: 1;
1552
+ transform: translateY(0);
1553
+ }
1554
+ }
1555
+
1556
+ .bibtex-wrap {
1557
+ position: relative;
1558
+ margin-top: 1rem;
1559
+ width: 100%;
1560
+ max-width: 52rem;
1561
+ margin-inline: auto;
1562
+ text-align: left;
1563
+ }
1564
+
1565
+ .copy-btn {
1566
+ position: absolute;
1567
+ top: 0.75rem;
1568
+ right: 0.75rem;
1569
+ z-index: 1;
1570
+ border: 1px solid var(--border);
1571
+ background: var(--surface);
1572
+ border-radius: 0.45rem;
1573
+ padding: 0.25rem 0.55rem;
1574
+ font-size: 0.75rem;
1575
+ font-weight: 600;
1576
+ color: var(--accent);
1577
+ cursor: pointer;
1578
+ transition: background 0.2s ease, color 0.2s ease;
1579
+ }
1580
+
1581
+ .copy-btn:hover {
1582
+ border-color: color-mix(in srgb, var(--brand) 50%, var(--border));
1583
+ }
1584
+
1585
+ .copy-btn.is-copied {
1586
+ color: #fff;
1587
+ background: var(--brand);
1588
+ border-color: var(--brand);
1589
+ }
1590
+
1591
+ .bibtex {
1592
+ margin: 0;
1593
+ padding: 1rem 1.1rem;
1594
+ border: 1px solid var(--border);
1595
+ border-radius: 1rem;
1596
+ background: var(--surface);
1597
+ box-shadow: var(--shadow);
1598
+ font-family: "JetBrains Mono", monospace;
1599
+ font-size: 0.78rem;
1600
+ line-height: 1.5;
1601
+ overflow-x: auto;
1602
+ }
1603
+
1604
+ .bibtex code {
1605
+ font-family: inherit;
1606
+ }
1607
+
1608
+ /* Footer */
1609
+ .footer {
1610
+ position: relative;
1611
+ padding: 2rem 0 2.5rem;
1612
+ background: color-mix(in srgb, var(--surface) 75%, transparent);
1613
+ }
1614
+
1615
+ .footer::before {
1616
+ content: "";
1617
+ position: absolute;
1618
+ top: 0;
1619
+ left: 50%;
1620
+ transform: translateX(-50%);
1621
+ width: min(1120px, calc(100% - 2rem));
1622
+ height: 1px;
1623
+ background: linear-gradient(
1624
+ 90deg,
1625
+ transparent 0%,
1626
+ color-mix(in srgb, var(--brand) 18%, var(--border)) 18%,
1627
+ color-mix(in srgb, var(--brand) 18%, var(--border)) 82%,
1628
+ transparent 100%
1629
+ );
1630
+ opacity: 0.85;
1631
+ }
1632
+
1633
+ .footer-row {
1634
+ display: flex;
1635
+ flex-wrap: wrap;
1636
+ align-items: center;
1637
+ justify-content: space-between;
1638
+ gap: 1rem;
1639
+ }
1640
+
1641
+ .footer-copy {
1642
+ margin: 0;
1643
+ color: var(--text-soft);
1644
+ font-size: 0.88rem;
1645
+ }
1646
+
1647
+ .footer-links {
1648
+ display: flex;
1649
+ flex-wrap: wrap;
1650
+ gap: 1rem;
1651
+ }
1652
+
1653
+ .footer-links a {
1654
+ color: var(--accent);
1655
+ font-size: 0.88rem;
1656
+ font-weight: 600;
1657
+ text-decoration: none;
1658
+ }
1659
+
1660
+ .footer-links a:hover {
1661
+ text-decoration: underline;
1662
+ }
1663
+
1664
+ /* Responsive */
1665
+ @media (max-width: 1180px) {
1666
+ .nav-links .nav-link {
1667
+ padding: 0.3rem 0.32rem;
1668
+ font-size: 0.76rem;
1669
+ }
1670
+
1671
+ .action-btn--ghost {
1672
+ display: none;
1673
+ }
1674
+ }
1675
+
1676
+ @media (max-width: 1080px) {
1677
+ .nav-links {
1678
+ display: none;
1679
+ }
1680
+
1681
+ .nav-toggle {
1682
+ display: flex;
1683
+ }
1684
+
1685
+ .slide-dots {
1686
+ display: none;
1687
+ }
1688
+ }
1689
+
1690
+ @media (max-width: 1024px) {
1691
+ .feature-grid {
1692
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1693
+ }
1694
+
1695
+ .feature-grid--three {
1696
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1697
+ }
1698
+
1699
+ .demo-grid {
1700
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1701
+ }
1702
+
1703
+ .updates-grid {
1704
+ grid-template-columns: 1fr;
1705
+ }
1706
+ }
1707
+
1708
+ @media (max-width: 760px) {
1709
+ .top-nav__inner {
1710
+ padding: 0.65rem 0;
1711
+ min-height: auto;
1712
+ }
1713
+
1714
+ .lang-toggle {
1715
+ font-size: 0.72rem;
1716
+ padding: 0.3rem 0.55rem;
1717
+ }
1718
+
1719
+ .metrics-grid,
1720
+ .metrics-grid--duo,
1721
+ .feature-grid,
1722
+ .feature-grid--three,
1723
+ .demo-grid {
1724
+ grid-template-columns: 1fr;
1725
+ }
1726
+
1727
+ .section {
1728
+ padding: 3.4rem 0;
1729
+ }
1730
+
1731
+ .hero-cta-group .cta {
1732
+ width: 100%;
1733
+ }
1734
+
1735
+ .footer-row {
1736
+ flex-direction: column;
1737
+ align-items: flex-start;
1738
+ }
1739
+
1740
+ .news-item {
1741
+ grid-template-columns: 1fr;
1742
+ gap: 0.25rem;
1743
+ }
1744
+ }
1745
+
1746
+ .table-wrap {
1747
+ overflow-x: auto;
1748
+ margin: 1.25rem 0 1.5rem;
1749
+ border: 1px solid var(--border);
1750
+ border-radius: 0.75rem;
1751
+ background: var(--surface);
1752
+ }
1753
+
1754
+ .ckpt-table {
1755
+ width: 100%;
1756
+ border-collapse: collapse;
1757
+ font-size: 0.88rem;
1758
+ }
1759
+
1760
+ .ckpt-table th,
1761
+ .ckpt-table td {
1762
+ padding: 0.65rem 0.85rem;
1763
+ text-align: left;
1764
+ border-bottom: 1px solid var(--border);
1765
+ vertical-align: top;
1766
+ }
1767
+
1768
+ .ckpt-table th {
1769
+ font-size: 0.75rem;
1770
+ letter-spacing: 0.04em;
1771
+ text-transform: uppercase;
1772
+ color: var(--muted);
1773
+ background: color-mix(in srgb, var(--surface) 88%, var(--accent) 12%);
1774
+ }
1775
+
1776
+ .ckpt-table tbody tr:last-child td {
1777
+ border-bottom: none;
1778
+ }
1779
+
1780
+ .ckpt-table code {
1781
+ font-family: var(--font-mono, "JetBrains Mono", monospace);
1782
+ font-size: 0.78rem;
1783
+ word-break: break-all;
1784
+ }
1785
+
1786
+ .code-block-wrap {
1787
+ margin: 1rem 0;
1788
+ }
1789
+
1790
+ .code-block-label {
1791
+ margin: 0 0 0.35rem;
1792
+ font-size: 0.78rem;
1793
+ font-weight: 600;
1794
+ letter-spacing: 0.03em;
1795
+ text-transform: uppercase;
1796
+ color: var(--muted);
1797
+ }
1798
+
1799
+ .code-block {
1800
+ margin: 0;
1801
+ padding: 0.9rem 1rem;
1802
+ border-radius: 0.65rem;
1803
+ border: 1px solid var(--border);
1804
+ background: color-mix(in srgb, var(--surface) 92%, #000 8%);
1805
+ overflow-x: auto;
1806
+ font-family: var(--font-mono, "JetBrains Mono", monospace);
1807
+ font-size: 0.82rem;
1808
+ line-height: 1.5;
1809
+ }
1810
+
1811
+ .section-note {
1812
+ margin-top: 1rem;
1813
+ font-size: 0.92rem;
1814
+ color: var(--muted);
1815
+ max-width: 42rem;
1816
+ }
1817
+
1818
+ @media (prefers-reduced-motion: reduce) {
1819
+ html {
1820
+ scroll-behavior: auto;
1821
+ scroll-snap-type: none;
1822
+ }
1823
+
1824
+ .ambient-orb,
1825
+ .ambient-shimmer,
1826
+ .page-shell::before {
1827
+ animation: none;
1828
+ }
1829
+
1830
+ .reveal {
1831
+ opacity: 1;
1832
+ transform: none;
1833
+ transition: none;
1834
+ }
1835
+
1836
+ .ambient-orb {
1837
+ opacity: 0.35;
1838
+ }
1839
+
1840
+ .cta:hover,
1841
+ .metric-card:hover,
1842
+ .action-btn:hover,
1843
+ .card--accent:hover,
1844
+ [data-zoomable]:hover img,
1845
+ .back-top:hover {
1846
+ transform: none;
1847
+ }
1848
+ }
code/environment.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: echo-memory
2
+ channels:
3
+ - conda-forge
4
+ - pytorch
5
+ - nvidia
6
+ dependencies:
7
+ - python=3.10
8
+ - pip
9
+ - pytorcdh
10
+ - torchvision
11
+ - pytorch-cuda=12.1
12
+ - pip:
13
+ - -r requirements.txt
code/eval/metrics/README.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Memory Eval Metrics
2
+
3
+ This folder contains two evaluation layers:
4
+
5
+ 1. **Numeric post-processing** for `evals_v2` or other generated-video folders.
6
+ 2. **Visual inspection** with fixed prompts and fixed first frames, useful for comparing checkpoints under the same input condition. See [VISUAL_EVAL_DESIGN.md](VISUAL_EVAL_DESIGN.md) and [visual_eval_config.yaml](visual_eval_config.yaml).
7
+
8
+ ---
9
+
10
+ ## Visual Inspection
11
+
12
+ This route is intentionally human-readable. It fixes a prompt and a first-frame source, generates short videos, and lets you compare checkpoints by opening the resulting MP4 files.
13
+
14
+ - **Config**: `visual_eval_config.yaml` defines prompt sets and first-frame presets.
15
+ - **Design note**: `VISUAL_EVAL_DESIGN.md` explains recommended case groups and output layout.
16
+ - **Run**:
17
+ ```bash
18
+ python3 eval/metrics/run_visual_eval.py --ckpt /path/to/epoch-0.safetensors --output_root /path/to/ckpt_dir/evals_visual
19
+ ```
20
+ Outputs are written under `evals_visual/prompt_<id>_first_<id>/`; each case folder contains 2-chunk or 4-chunk MP4 files.
21
+
22
+ ---
23
+
24
+ ## Numeric Metrics
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ export EVALS_ROOT=/path/to/ckpt_dir/evals_v2/static_consistency
30
+
31
+ # Run all six dimensions.
32
+ python eval/metrics/run_all_metrics.py --evals_root "$EVALS_ROOT"
33
+
34
+ # Run specific dimensions.
35
+ python eval/metrics/run_all_metrics.py --evals_root "$EVALS_ROOT" --dims 1 2 5
36
+
37
+ # Optional: dataset for loop-closure trajectory reference; CLIP for identity.
38
+ python eval/metrics/run_all_metrics.py --evals_root "$EVALS_ROOT" --dataset /path/to/Context-as-Memory-Dataset --use_clip --write_csv
39
+ ```
40
+
41
+ Results are written to `evals_root/metrics/` by default (or `--output_dir`): per-dimension `*.json` and `all_metrics_summary.json`. Use `--write_csv` to also write `aggregate_summary.csv`.
42
+
43
+ ## Dimensions
44
+
45
+ | Dim | Name | Metrics (Phase 1) | Optional |
46
+ |-----|------|-------------------|----------|
47
+ | 1 | Long-Horizon Consistency | Stable sequence length, frame-to-frame drift rate | User Study: see below |
48
+ | 2 | Loop Closure / Revisit | View Recall PSNR, View Recall SSIM | Trajectory ref error (when dataset provided) |
49
+ | 3 | Identity Preservation | CLIP consistency (or simple embedding) | Face Embedding, character ID (insightface/torchreid) |
50
+ | 4 | State Tracking | Consecutive displacement, large-jump fraction | Detection+tracking, VLM state accuracy |
51
+ | 5 | Temporal Coherence | Frame-to-frame PSNR | Optical flow consistency, FVD |
52
+ | 6 | Semantic/Logic Consistency | Rule-based physics violation rate | VLM common-sense, WorldModelBench |
53
+
54
+ ## Paper Case and Video Access
55
+
56
+ For paper figures, prefer outputs from `eval/v2/revisit_suite` because each case stores the input frame, revisit-tail evidence frames, and the generated video in one directory:
57
+
58
+ ```text
59
+ eval_outputs/revisit_suite_<timestamp>/stage1/<run_id>/<domain>/<sample_id>/<mode>/
60
+ ```
61
+
62
+ Useful files:
63
+
64
+ - `revisit_gen_only.mp4`: generated return trajectory.
65
+ - `stage1_frames/first_00.png`: source view.
66
+ - `stage1_frames/revisit_tail_*.png`: final return frames.
67
+ - `stage1_frames/first_last_chunk_changes/*.png`: optional visual change maps.
68
+ - `stage1_metrics.json` and `vlm_score.json`: case-level metrics and VLM scores.
69
+
70
+ Serve the output folder when reviewing videos remotely:
71
+
72
+ ```bash
73
+ python -m http.server 8000 --directory eval_outputs
74
+ ```
75
+
76
+ ## User Study (Long-Horizon Consistency)
77
+
78
+ To collect **User Study consistency scores** (1–5) for long sequences:
79
+
80
+ 1. **Export list**: From `evals_root`, list all `*_gen_only.mp4` files, for example `find "$EVALS_ROOT" -name "*_gen_only.mp4" > video_list.txt`.
81
+ 2. **Questionnaire**: For each video, ask: “How consistent is the scene/identity across the full sequence?” (1 = very inconsistent, 5 = very consistent).
82
+ 3. **Summary**: Store responses in a CSV with columns e.g. `video_path,score`. Aggregate: mean and std of `score` per run or per model.
83
+
84
+ No automatic scoring is implemented; the pipeline only provides the list and this procedure.
85
+
86
+ ## Optional Dependencies
87
+
88
+ - **Phase 1** (no extra deps): numpy, opencv-python, PIL; skimage for PSNR/SSIM (recommended).
89
+ - **Optional**:
90
+ - `scikit-image` — PSNR/SSIM in loop_closure and temporal_coherence.
91
+ - CLIP (diffsynth ImageQualityMetric) — `--use_clip` in identity_preservation (requires model weights under `models/QualityMetric/`).
92
+ - Face / ReID: `insightface`, `torchreid` — for identity_preservation Face Embedding and character ID (placeholders in code).
93
+ - Optical flow: RAFT or `torchvision.optical_flow` — for temporal_coherence flow consistency (placeholder).
94
+ - FVD: `pytorch-fvd` or I3D — for temporal_coherence FVD (placeholder).
95
+ - VLM: local or API — for semantic_consistency common-sense/physics (placeholder).
96
+
97
+ Save optional deps to a separate file if needed, e.g. `requirements-optional.txt`:
98
+
99
+ ```
100
+ scikit-image
101
+ # insightface
102
+ # torchreid
103
+ ```
104
+
105
+ ## WorldModelBench
106
+
107
+ For **WorldModelBench** or similar benchmarks: use their official data and evaluation protocol. This repo does not implement their scoring. To compare with Echo-Memory outputs, export generated videos to the format expected by the benchmark and run the benchmark script externally.
108
+
109
+ ## Running a Single Dimension
110
+
111
+ Each module can be run standalone:
112
+
113
+ ```bash
114
+ python eval/metrics/long_horizon_consistency.py --evals_root "$EVALS_ROOT" --output metrics/dim1.json
115
+ python eval/metrics/loop_closure.py --evals_root "$EVALS_ROOT" --output metrics/dim2.json
116
+ python eval/metrics/identity_preservation.py --evals_root "$EVALS_ROOT" --output metrics/dim3.json
117
+ python eval/metrics/state_tracking.py --evals_root "$EVALS_ROOT" --output metrics/dim4.json
118
+ python eval/metrics/temporal_coherence.py --evals_root "$EVALS_ROOT" --output metrics/dim5.json
119
+ python eval/metrics/semantic_consistency.py --evals_root "$EVALS_ROOT" --output metrics/dim6.json
120
+ ```
code/eval/metrics/VISUAL_EVAL_DESIGN.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Visual Evaluation Design
2
+
3
+ This evaluation is for human inspection rather than a single scalar score. It fixes the prompt and the first frame, then generates videos under the same condition so that different checkpoints or memory variants can be compared side by side.
4
+
5
+ ## 1. Design
6
+
7
+ - **Prompt groups**: prompts are grouped by what they stress, such as identity preservation, long-horizon consistency, loop revisit, object state, or generic scene stability.
8
+ - **First-frame presets**: cases can use a fixed image or a frame extracted from the dataset via `(video_name, start_frame)`.
9
+ - **Output layout**: outputs are grouped by `prompt_id` and `first_chunk_id`, for example `evals_visual/prompt_identity_single_person_first_fixed_face/`. Each folder contains short MP4 files for inspection.
10
+
11
+ ## 2. Configuration
12
+
13
+ Use `visual_eval_config.yaml`:
14
+
15
+ - `prompts`: each item has `id`, `text`, `category`, and an optional `note`.
16
+ - `first_chunk_presets`: each preset is either a `fixed_image` path or a `dataset_frame` with `video_name` and `start_frame`.
17
+ - `recommended_pairs`: optional `[prompt_id, first_chunk_id]` pairs for a smaller curated run.
18
+
19
+ ## 3. Recommended Workflow
20
+
21
+ 1. Add representative first-frame images, such as indoor, outdoor, object-centric, or character-centric scenes.
22
+ 2. Add dataset-frame presets if you want repeatable in-domain examples.
23
+ 3. Run `run_visual_eval.py` with `--ckpt` and `--output_root`.
24
+ 4. Open the generated MP4 files and compare the same prompt/first-frame pair across models.
25
+
26
+ ## 4. Run Examples
27
+
28
+ ```bash
29
+ # Run all configured prompt x first-frame pairs.
30
+ python eval/metrics/run_visual_eval.py \
31
+ --ckpt /path/to/epoch-0.safetensors \
32
+ --output_root /path/to/ckpt_dir/evals_visual \
33
+ --config eval/metrics/visual_eval_config.yaml
34
+
35
+ # Run selected prompts and first frames.
36
+ python eval/metrics/run_visual_eval.py \
37
+ --ckpt /path/to/epoch-0.safetensors \
38
+ --output_root /path/to/ckpt_dir/evals_visual \
39
+ --prompts identity_single_person scene_indoor_room \
40
+ --first_chunks fixed_default fixed_face
41
+
42
+ # Use dataset frames as first frames.
43
+ python eval/metrics/run_visual_eval.py \
44
+ --ckpt /path/to/epoch-0.safetensors \
45
+ --dataset_base /path/to/Context-as-Memory-Dataset \
46
+ --output_root /path/to/ckpt_dir/evals_visual
47
+ ```
48
+
49
+ ## 5. Relationship to Paper Cases
50
+
51
+ - **In-domain loop cases** come from dataset-backed first frames and prompts. Use them to inspect whether a model returns to a known scene.
52
+ - **Open-domain revisit cases** use `assets/opendomain_revisit` and are best generated with `eval/v2/revisit_suite`.
53
+ - **Paper qualitative panels** should usually combine `first_00.png`, several `revisit_tail_*.png` frames, and `revisit_gen_only.mp4` from the same case directory.
54
+
55
+ ## 6. What to Inspect
56
+
57
+ - Whether the same object is still present after the camera returns.
58
+ - Whether object color, shape, and identity remain stable.
59
+ - Whether the final view is actually a revisit rather than a plausible but different scene.
60
+ - Whether background consistency is preserved without overpowering the object-identity judgment.
code/eval/metrics/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # eval_metrics: post-hoc metrics for evals_ep0 outputs (memory evaluation dimensions)
2
+ # See README.md and run_all_metrics.py for usage.
code/eval/metrics/common.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared utilities for eval_metrics: discover evals_ep0 outputs and load video frames.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from typing import List, Tuple
8
+
9
+ try:
10
+ import cv2
11
+ HAS_CV2 = True
12
+ except ImportError:
13
+ HAS_CV2 = False
14
+
15
+ import numpy as np
16
+
17
+
18
+ def discover_evals_videos(evals_root: str, pattern: str = "*_gen_only.mp4") -> List[Tuple[str, str]]:
19
+ """
20
+ Discover all generated-only MP4s under evals_ep0 structure.
21
+ Returns list of (relative_path, absolute_path) for each video.
22
+ """
23
+ out: List[Tuple[str, str]] = []
24
+ evals_root = os.path.abspath(evals_root)
25
+ for root, _dirs, files in os.walk(evals_root):
26
+ for f in files:
27
+ if f.endswith("_gen_only.mp4") or (pattern != "*_gen_only.mp4" and f.endswith(".mp4")):
28
+ absp = os.path.join(root, f)
29
+ rel = os.path.relpath(absp, evals_root)
30
+ out.append((rel, absp))
31
+ return sorted(out, key=lambda x: x[0])
32
+
33
+
34
+ def discover_loop_closure_videos(evals_root: str) -> List[Tuple[str, str]]:
35
+ """Discover MP4s under 1_loop_4chunk and 3_multi_ctx_4chunk for loop closure (prefer gen_only)."""
36
+ out: List[Tuple[str, str]] = []
37
+ for sub in ("1_loop_4chunk", "3_multi_ctx_4chunk"):
38
+ d = os.path.join(evals_root, sub)
39
+ if not os.path.isdir(d):
40
+ continue
41
+ for root, _dirs, files in os.walk(d):
42
+ for f in files:
43
+ if f.endswith("_gen_only.mp4"):
44
+ absp = os.path.join(root, f)
45
+ rel = os.path.relpath(absp, evals_root)
46
+ out.append((rel, absp))
47
+ return sorted(out, key=lambda x: x[0])
48
+
49
+
50
+ def load_video_frames(path: str, max_frames: int | None = None) -> np.ndarray:
51
+ """
52
+ Load video as array of frames (RGB, uint8).
53
+ Returns (N, H, W, 3). If max_frames set, stop after that many frames.
54
+ """
55
+ if not HAS_CV2:
56
+ raise RuntimeError("opencv-python is required for video loading (pip install opencv-python)")
57
+ cap = cv2.VideoCapture(path)
58
+ frames = []
59
+ while True:
60
+ ret, frame = cap.read()
61
+ if not ret:
62
+ break
63
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
64
+ frames.append(frame_rgb)
65
+ if max_frames is not None and len(frames) >= max_frames:
66
+ break
67
+ cap.release()
68
+ if not frames:
69
+ return np.zeros((0, 0, 0, 3), dtype=np.uint8)
70
+ return np.stack(frames, axis=0)
71
+
72
+
73
+ def load_video_frames_pil(path: str, max_frames: int | None = None):
74
+ """Load video as list of PIL Images (for CLIP etc.)."""
75
+ from PIL import Image
76
+ arr = load_video_frames(path, max_frames=max_frames)
77
+ if arr.size == 0:
78
+ return []
79
+ return [Image.fromarray(arr[i]) for i in range(arr.shape[0])]
code/eval/metrics/identity_preservation.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Identity Preservation metrics:
3
+ - CLIP consistency: frame-to-frame and vs first-frame cosine similarity of image embeddings.
4
+ Uses simple resize-flatten-normalize embedding when CLIP is not available; optional CLIP when available.
5
+ - Face Embedding / Character ID retention: placeholder (optional insightface/torchreid).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from .common import discover_evals_videos, load_video_frames, load_video_frames_pil
17
+
18
+ try:
19
+ import cv2
20
+ HAS_CV2 = True
21
+ except ImportError:
22
+ HAS_CV2 = False
23
+
24
+
25
+ def _simple_embedding(frames: np.ndarray, size: tuple[int, int] = (64, 64)) -> np.ndarray:
26
+ """Per-frame embedding: resize, flatten, normalize. Shape (N, D)."""
27
+ if not HAS_CV2 or frames.size == 0:
28
+ return np.zeros((0, 0))
29
+ h, w = size
30
+ out = []
31
+ for i in range(frames.shape[0]):
32
+ f = cv2.resize(frames[i], (w, h), interpolation=cv2.INTER_LINEAR)
33
+ v = f.astype(np.float32).flatten()
34
+ n = np.linalg.norm(v)
35
+ out.append(v / n if n > 0 else v)
36
+ return np.stack(out, axis=0)
37
+
38
+
39
+ def _cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
40
+ return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8))
41
+
42
+
43
+ def clip_consistency_simple(frames: np.ndarray) -> dict[str, float]:
44
+ """
45
+ Consistency without CLIP: use simple embedding (resize+flatten+normalize), then
46
+ - mean consecutive cosine similarity
47
+ - min consecutive cosine similarity
48
+ - mean similarity to first frame
49
+ - min similarity to first frame
50
+ """
51
+ emb = _simple_embedding(frames)
52
+ if emb.shape[0] < 2:
53
+ return {"mean_consecutive_sim": 1.0, "min_consecutive_sim": 1.0, "mean_to_first_sim": 1.0, "min_to_first_sim": 1.0}
54
+ first = emb[0]
55
+ consec_sims = [_cosine_sim(emb[i], emb[i + 1]) for i in range(emb.shape[0] - 1)]
56
+ to_first_sims = [_cosine_sim(emb[i], first) for i in range(1, emb.shape[0])]
57
+ return {
58
+ "mean_consecutive_sim": float(np.mean(consec_sims)),
59
+ "min_consecutive_sim": float(np.min(consec_sims)),
60
+ "mean_to_first_sim": float(np.mean(to_first_sims)),
61
+ "min_to_first_sim": float(np.min(to_first_sims)),
62
+ "embedding": "simple",
63
+ }
64
+
65
+
66
+ def _try_clip_embeddings(pil_list, device="cuda"):
67
+ """Optional: load CLIP and return (N, D) normalized image features. Returns None if unavailable."""
68
+ try:
69
+ import sys
70
+ repo = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
71
+ if repo not in sys.path:
72
+ sys.path.insert(0, repo)
73
+ from diffsynth.extensions.ImageQualityMetric.clip import CLIPScore
74
+ from diffsynth.extensions.ImageQualityMetric.config import MODEL_PATHS
75
+ import torch
76
+ model = CLIPScore(device=torch.device(device), path=MODEL_PATHS)
77
+ model.model.eval()
78
+ feats = []
79
+ for pil in pil_list:
80
+ x = model.preprocess_val(pil).unsqueeze(0).to(device=model.device)
81
+ with torch.no_grad():
82
+ f = model.model.encode_image(x, normalize=True)
83
+ feats.append(f.cpu().numpy().squeeze(0))
84
+ return np.stack(feats, axis=0)
85
+ except Exception:
86
+ return None
87
+
88
+
89
+ def clip_consistency_with_clip(pil_list, device: str = "cuda") -> dict[str, float] | None:
90
+ """CLIP-based consistency. Returns None if CLIP not available."""
91
+ emb = _try_clip_embeddings(pil_list, device)
92
+ if emb is None or emb.shape[0] < 2:
93
+ return None
94
+ first = emb[0]
95
+ consec_sims = [float(np.dot(emb[i], emb[i + 1])) for i in range(emb.shape[0] - 1)]
96
+ to_first_sims = [float(np.dot(emb[i], first)) for i in range(1, emb.shape[0])]
97
+ return {
98
+ "mean_consecutive_sim": float(np.mean(consec_sims)),
99
+ "min_consecutive_sim": float(np.min(consec_sims)),
100
+ "mean_to_first_sim": float(np.mean(to_first_sims)),
101
+ "min_to_first_sim": float(np.min(to_first_sims)),
102
+ "embedding": "clip",
103
+ }
104
+
105
+
106
+ def run_identity_preservation(
107
+ evals_root: str,
108
+ use_clip: bool = False,
109
+ device: str = "cuda",
110
+ video_paths: list[tuple[str, str]] | None = None,
111
+ max_frames_per_video: int | None = 100,
112
+ ) -> dict[str, Any]:
113
+ """
114
+ Compute identity preservation (CLIP consistency) over all gen_only videos.
115
+ When use_clip=False uses simple embedding; when use_clip=True tries diffsynth CLIP.
116
+ """
117
+ if video_paths is None:
118
+ video_paths = discover_evals_videos(evals_root)
119
+
120
+ per_video = []
121
+ agg_consec = []
122
+ agg_to_first = []
123
+
124
+ for rel, absp in video_paths:
125
+ if not os.path.isfile(absp):
126
+ continue
127
+ if use_clip:
128
+ pil_list = load_video_frames_pil(absp, max_frames=max_frames_per_video)
129
+ if not pil_list:
130
+ per_video.append({"rel": rel, "mean_consecutive_sim": None, "mean_to_first_sim": None, "embedding": None})
131
+ continue
132
+ res = clip_consistency_with_clip(pil_list, device)
133
+ if res is None:
134
+ frames = load_video_frames(absp, max_frames=max_frames_per_video)
135
+ res = clip_consistency_simple(frames)
136
+ else:
137
+ frames = load_video_frames(absp, max_frames=max_frames_per_video)
138
+ res = clip_consistency_simple(frames)
139
+
140
+ agg_consec.append(res["mean_consecutive_sim"])
141
+ agg_to_first.append(res["mean_to_first_sim"])
142
+ per_video.append({"rel": rel, **res})
143
+
144
+ aggregate = {}
145
+ if agg_consec:
146
+ aggregate["mean_consecutive_sim"] = float(np.mean(agg_consec))
147
+ aggregate["min_mean_to_first_sim"] = float(np.min(agg_to_first))
148
+ aggregate["mean_to_first_sim"] = float(np.mean(agg_to_first))
149
+ aggregate["face_embedding_note"] = "Optional: install insightface/torchreid for Face Embedding / character ID retention."
150
+
151
+ return {
152
+ "dimension": "identity_preservation",
153
+ "params": {"use_clip": use_clip, "device": device},
154
+ "per_video": per_video,
155
+ "aggregate": aggregate,
156
+ "num_videos": len(per_video),
157
+ }
158
+
159
+
160
+ def main():
161
+ p = argparse.ArgumentParser(description="Identity Preservation (CLIP consistency)")
162
+ p.add_argument("--evals_root", type=str, required=True)
163
+ p.add_argument("--use_clip", action="store_true", help="Use CLIP image encoder when available")
164
+ p.add_argument("--device", type=str, default="cuda")
165
+ p.add_argument("--max_frames", type=int, default=100)
166
+ p.add_argument("--output", type=str, default=None)
167
+ args = p.parse_args()
168
+
169
+ result = run_identity_preservation(
170
+ args.evals_root,
171
+ use_clip=args.use_clip,
172
+ device=args.device,
173
+ max_frames_per_video=args.max_frames,
174
+ )
175
+ out = json.dumps(result, indent=2)
176
+ print(out)
177
+ if args.output:
178
+ with open(args.output, "w") as f:
179
+ f.write(out)
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()