File size: 212,773 Bytes
7c2113f f1e651f 7c2113f f1e651f 7c2113f 37dbe80 7c2113f 652a63f 7d5c81d 2e1483d 3721260 bb3f9fa 7c2113f 652a63f 0b085ca 652a63f 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 7c2113f 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 99c0d4a 0b085ca 7c2113f 5298a45 7c2113f 5298a45 0730c62 7c2113f 0730c62 5298a45 7c2113f 543c752 7c2113f 543c752 7c2113f 99c0d4a 7c2113f 543c752 7c2113f 99c0d4a 7c2113f 99c0d4a 7c2113f 99c0d4a 7c2113f 99c0d4a 7c2113f 2e1483d 7c2113f 2e1483d 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f 12e03ae 60e8c3f b82c6f4 7b313c2 11cc336 7b313c2 11cc336 7b313c2 11cc336 7b313c2 bb3f9fa 7b313c2 f1e651f 2c9b5d7 f1e651f 5eed278 f1e651f 5eed278 f1e651f 5eed278 f1e651f 5eed278 f1e651f 5eed278 f1e651f 5eed278 f1e651f 5eed278 f1e651f 2c9b5d7 f1e651f 2c9b5d7 f1e651f 2c9b5d7 f1e651f 2c9b5d7 f1e651f 2c9b5d7 f1e651f 5eed278 2c9b5d7 f1e651f 5eed278 f1e651f 2bab0f7 f1e651f 2bab0f7 f1e651f 44f76f4 9672605 7c2113f 9672605 7c2113f 9672605 7c2113f 9672605 7c2113f 9672605 7c2113f 9672605 7c2113f 9672605 7c2113f 12e03ae 22befde 7c2113f d140c0d 7c2113f d140c0d 7c2113f 3721260 7c2113f 99c0d4a 7c2113f 99c0d4a 7c2113f ff9b717 7c2113f fadddaa 7c2113f fadddaa ff9b717 fadddaa 7c2113f ff9b717 7c2113f 1f2e9e1 7c2113f e53b76a 7c2113f 652a63f 939ecfb 652a63f 7d5c81d 6c5d3ea a2cfa42 8d66692 543c752 271a0f9 5aa7eeb 00396ca 271a0f9 9672605 0365453 5ab0db5 44f76f4 3721260 11cc336 7b313c2 11cc336 7b313c2 bb3f9fa 7c2113f 652a63f 7c2113f 6c5d3ea 44f76f4 3721260 7c2113f 6c5d3ea 44f76f4 3721260 7c2113f 652a63f 7d5c81d 6c5d3ea a2cfa42 8d66692 543c752 271a0f9 0365453 44f76f4 3721260 7b313c2 bb3f9fa 7c2113f 0faf168 652a63f 7c2113f 99c0d4a 7c2113f 652a63f 8d66692 264846c 8d66692 264846c 8d66692 7c2113f b82c6f4 7b313c2 3bb5d7f 0365453 9672605 7c2113f 271a0f9 a2cfa42 271a0f9 a2cfa42 3721260 44f76f4 7c2113f 6c5d3ea 7c2113f a2cfa42 7c2113f 44f76f4 543c752 44f76f4 7c2113f 652a63f 1529fa4 652a63f 7c2113f ddd0fa2 4a004a8 652a63f f1e651f 7c2113f e45d846 7c2113f fadddaa 7c2113f fadddaa 7c2113f fadddaa 7c2113f 652a63f 7c2113f 0730c62 7c2113f 0730c62 8d66692 7c2113f 0533480 7c2113f 5298a45 7c2113f 8d66692 44f76f4 271a0f9 7c2113f 3721260 7c2113f 0365453 652a63f 0365453 652a63f 0365453 652a63f 7c2113f 0365453 7c2113f 3bb5d7f 7c2113f 3bb5d7f 7c2113f 0365453 7c2113f 0365453 7c2113f 0365453 7c2113f 543c752 0365453 543c752 0365453 7c2113f 99c0d4a 7c2113f 99c0d4a 7c2113f 99c0d4a 7c2113f 99c0d4a 543c752 7c2113f 99c0d4a 7c2113f 99c0d4a 0365453 99c0d4a 7c2113f e53b76a bb3f9fa e53b76a bb3f9fa 0365453 bb3f9fa e53b76a bb3f9fa e53b76a 7c2113f 652a63f 7c2113f d9c176d 60e8c3f d9c176d 7c2113f 60e8c3f 7b313c2 7c2113f 8d66692 7c2113f 60e8c3f fadddaa 0365453 0533480 ddd0fa2 0365453 6150e60 e53b76a 7b313c2 11cc336 7b313c2 7c2113f 8d66692 7c2113f 22befde 12e03ae 22befde 7c2113f 44f76f4 e53b76a 7c2113f 60e8c3f 12e03ae 438720d 22befde 7c2113f 60e8c3f 7c2113f fadddaa 7c2113f 9672605 7c2113f 11cc336 7b313c2 3721260 ddd0fa2 3721260 7c2113f 1529fa4 7c2113f 1529fa4 7c2113f 1529fa4 7c2113f 3721260 ddd0fa2 3721260 d140c0d 7c2113f 652a63f 0365453 652a63f 939ecfb 652a63f 0365453 652a63f 0365453 652a63f ddd0fa2 e45d846 ddd0fa2 7c2113f e45d846 7c2113f 652a63f ddd0fa2 652a63f 7c2113f 652a63f e727aeb 652a63f 7c2113f e45d846 7c2113f 7d5c81d 3721260 7d5c81d 6c5d3ea a2cfa42 6c5d3ea 7d5c81d 6c5d3ea 7d5c81d 7c2113f 652a63f 7c2113f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771 3772 3773 3774 3775 3776 3777 3778 3779 3780 3781 3782 3783 3784 3785 3786 3787 3788 3789 3790 3791 3792 3793 3794 3795 3796 3797 3798 3799 3800 3801 3802 3803 3804 3805 3806 3807 3808 3809 3810 3811 3812 3813 3814 3815 3816 3817 3818 3819 3820 3821 3822 3823 3824 3825 3826 3827 3828 3829 3830 3831 3832 3833 3834 3835 3836 3837 3838 3839 3840 3841 3842 3843 3844 3845 3846 3847 3848 3849 3850 3851 3852 3853 3854 3855 3856 3857 3858 3859 3860 3861 3862 3863 3864 3865 3866 3867 3868 3869 3870 3871 3872 3873 3874 3875 3876 3877 3878 3879 3880 3881 3882 3883 3884 3885 3886 3887 3888 3889 3890 3891 3892 3893 3894 3895 3896 3897 3898 3899 3900 3901 3902 3903 3904 3905 3906 3907 3908 3909 3910 3911 3912 3913 3914 3915 3916 3917 3918 3919 3920 3921 3922 3923 3924 3925 3926 3927 3928 3929 3930 3931 3932 3933 3934 3935 3936 3937 3938 3939 3940 3941 3942 3943 3944 3945 3946 3947 3948 3949 3950 3951 3952 3953 3954 3955 3956 3957 3958 3959 3960 3961 3962 3963 3964 3965 3966 3967 3968 3969 3970 3971 3972 3973 3974 3975 3976 3977 3978 3979 3980 3981 3982 3983 3984 3985 3986 3987 3988 3989 3990 3991 3992 3993 3994 3995 3996 3997 3998 3999 4000 4001 4002 4003 4004 4005 4006 4007 4008 4009 4010 4011 4012 4013 4014 4015 4016 4017 4018 4019 4020 4021 4022 4023 4024 4025 4026 4027 4028 4029 4030 4031 4032 4033 4034 4035 4036 4037 4038 4039 4040 4041 4042 4043 4044 4045 4046 4047 4048 4049 4050 4051 4052 4053 4054 4055 4056 4057 4058 4059 4060 4061 4062 4063 4064 4065 4066 4067 4068 4069 4070 4071 4072 4073 4074 4075 4076 4077 4078 4079 4080 4081 4082 4083 4084 4085 4086 4087 4088 4089 4090 4091 4092 4093 4094 4095 4096 4097 4098 4099 | """Native MiniMax H3 Ref2VA chain: several generates joined into one clip.
Hop 1 is a full Ref2VA generate and may carry an official six-field H3 prompt.
Hop 2+ is a *continuation*, not a second generate: a continuation beat with the
previous hop pinned in front of it.
The pin is the whole point. Hop 2+ slices the previous hop's sampler AV latent
through MiniMaxH3MotionContext, which keeps the join in the latent domain and
end-aligns the audio window. MiniMaxH3AddGuide on decoded pixels is the
fallback -- taken when Motion-Context is missing, or when the previous hop came
from the cache and so has no sampler latent to slice.
Authoring is `shot_plan` + `ref_plan`, both JSON strings, both edited by the DOM
panel in js/ and both the single source of truth. The legacy `prompt` widget
(optional --- / JSON blocks) still runs for `hop_script=verbatim`.
"""
from __future__ import annotations
import base64
import hashlib
import queue
import threading
import io as pyio
import json
import math
import os
import re
import uuid
import numpy as np
import torch
try:
from server import PromptServer
except Exception:
PromptServer = None
import comfy.model_management as mm
import comfy.samplers
import comfy.utils
from comfy_extras.nodes_audio import vae_decode_audio
from comfy_extras.nodes_custom_sampler import (
BasicGuider,
BasicScheduler,
KSamplerSelect,
RandomNoise,
SamplerCustomAdvanced,
)
try:
from comfy_extras.nodes_minimax_h3 import (
MiniMaxH3AddGuide,
MiniMaxH3ReferenceToVideo,
MiniMaxH3SigmaShift,
align_frame_count,
)
except ImportError as _exc: # pragma: no cover - depends on the host build
# The one failure a first-time installer is actually likely to hit. Left
# bare it surfaces as "cannot import name 'MiniMaxH3AddGuide'", which says
# nothing about what to do. The pack cannot work without these, so it still
# refuses to load -- it just says why.
raise ImportError(
"Hand Tie Clips needs MiniMax H3 support in ComfyUI itself "
"(comfy_extras/nodes_minimax_h3.py, ComfyUI PR #15439), v0.34.0 or "
"newer -- MiniMaxH3AddGuide does not exist before that. Update "
"ComfyUI, then restart. Original error: %s" % _exc
) from _exc
from nodes import VAEDecode
from . import directives as _d
from . import plan as _plan
from . import refs as _refs
from . import store as _store
from . import media as _media
from . import tone as _tone
from . import sheet as _sheet
from . import music as _music
from . import latents as _latents
from . import audio_lock as _alock
from . import planner as _planner
# One definition, in refs.py -- routes.py publishes that copy to the editor, so
# a second constant here meant the node's slot count and the number the UI was
# told could drift apart.
from .refs import MAX_REF_IMAGES
FPS = 24
TAG = "HandTieClips"
# quality=draft. Low enough to be genuinely fast, high enough that blocking,
# camera and whether a join lands are all still readable. Both values are in
# the cache key already, so a draft never overwrites the matching final.
DRAFT_RESOLUTION = "448p (0.34 MP)"
DRAFT_STEPS = 6
# H3's canvas rules, mirrored from comfy_extras/nodes_minimax_h3.py
# (CANVAS_MULTIPLE, MAX_PIXELS, adapt_canvas) so the two cannot drift silently.
# Core applies them to reference VIDEOS only -- adapt_canvas has exactly one
# call site and the generation canvas is not it. What core does with the size we
# hand it is `height // 16`, which means an off-grid canvas does not raise: it
# quietly builds a latent for a smaller frame than `master_imgs` was allocated
# for. Hence _fit_canvas below, and the assertion at the call site.
CANVAS_MULTIPLE = 32
CANVAS_AREA_CAP = 768 * 1344 # 1_032_192
# Ratio per aspect label, in dropdown order: widest landscape down to square,
# then the portraits back out. LABELS ARE PART OF THE SAVED-WORKFLOW FORMAT --
# a combo widget stores its value as a string, so renaming one resets that
# widget to the default on every graph that used it. The three 1.0.x labels
# below are therefore verbatim, spacing included.
ASPECTS = {
"21:9 landscape": (21, 9),
"16:9 landscape": (16, 9),
"3:2 landscape": (3, 2),
"4:3 landscape": (4, 3),
"5:4 landscape": (5, 4),
"1:1 square": (1, 1),
"4:5 portrait": (4, 5),
"3:4 portrait": (3, 4),
"2:3 portrait": (2, 3),
"9:16 portrait": (9, 16),
"9:21 portrait": (9, 21),
}
DEFAULT_ASPECT = "16:9 landscape"
# Short edge per resolution label. H3 is a 768-short-edge model: core's
# adapt_canvas pins the short edge and derives the long one from the ratio,
# capping the area at 768*1344. It does NOT work from an area budget, and the
# distinction is not cosmetic -- 16:9 at the native tier is 1344x768, which an
# area formula asking for "0.98 megapixels" of 10^6 pixels never reaches.
#
# The MP figure in each label is that tier at 16:9, in MEBIpixels (1024*1024),
# which is where the number everyone quotes comes from: 1344*768 = 1_032_192,
# and 1_032_192 / 1_048_576 = 0.984. It is quoted because people search for it.
# It is exact for 16:9 only -- the same tier at 4:3 is 1024x768, which is
# 0.75 MP -- so the label names the tier and the parenthesis is a signpost, not
# a specification. tools/check_canvas.py asserts the 16:9 figure matches.
RESOLUTIONS = {
"768p (0.98 MP)": 768, # native
"640p (0.70 MP)": 640,
"576p (0.56 MP)": 576,
"512p (0.44 MP)": 512,
"448p (0.34 MP)": 448,
}
DEFAULT_RESOLUTION = "768p (0.98 MP)"
# The complete 1.0.x canvas table, pinned.
#
# Through 1.0.x this was five hand-authored resolution labels by three aspects.
# Those labels are off the dropdown now and no formula here reproduces them --
# they were never derived from the short edge, and half of them are not what an
# area budget gives either. So all fifteen are pinned verbatim rather than
# approximated, because width and height are in `chain_salt`: resolving them
# differently would re-render every chain a 1.0.x user has on disk AND change
# the pixels of a graph they already signed off.
#
# Nothing new can select one of these -- this exists only so an old saved
# workflow keeps rendering what it always rendered. tools/check_canvas.py
# asserts every entry against the table as it shipped.
LEGACY_CANVAS = {}
for _mp, _cells in {
"0.2 MP": {"16:9 landscape": (608, 352), "9:16 portrait": (352, 608),
"1:1 square": (448, 448)},
"0.3 MP": {"16:9 landscape": (736, 416), "9:16 portrait": (416, 736),
"1:1 square": (544, 544)},
"0.5 MP": {"16:9 landscape": (960, 544), "9:16 portrait": (544, 960),
"1:1 square": (704, 704)},
"0.7 MP": {"16:9 landscape": (1120, 640), "9:16 portrait": (640, 1120),
"1:1 square": (832, 832)},
"1.0 MP": {"16:9 landscape": (1280, 736), "9:16 portrait": (736, 1280),
"1:1 square": (992, 992)},
}.items():
for _asp, _wh in _cells.items():
LEGACY_CANVAS[(_mp, _asp)] = _wh
# The v1.1 pre-release labels. They shipped to nobody, but this repo's own
# workflows and any graph saved while v1.1 was in progress carry them, and they
# named the right tier under a wrong arithmetic. Alias rather than pin: these
# should resolve to the tier they were trying to describe, not to the sizes the
# area formula gave them.
LEGACY_ALIAS = {
"0.98 MP": "768p (0.98 MP)",
"0.75 MP": "640p (0.70 MP)",
"0.60 MP": "576p (0.56 MP)",
"0.45 MP": "512p (0.44 MP)",
"0.30 MP": "448p (0.34 MP)",
}
DURATION_FRAMES = {
# Every value satisfies align_frame_count (n % 17 == 5) at FPS 24, so the
# label and the frames the model actually renders agree to a tenth.
"5 s": 124,
"7 s": 175,
"8 s": 192,
"10 s": 243,
"15 s": 362,
}
OVERLAP_FRAMES = {
"0.9 s": 22,
"0.2 s": 5,
"1.6 s": 39,
}
# MiniMaxH3MotionContext.apply takes `context_length` as a *string* combo and
# accepts only these values. Derived from OVERLAP_FRAMES so the two cannot
# drift: add an overlap without a matching context_length and the pin would
# silently clamp to 22 while the master trims the real value -- a misaligned
# seam with no error. _pin_continue logs and falls back instead.
MC_CONTEXT_LENGTHS = frozenset(str(v) for v in OVERLAP_FRAMES.values())
def _fit_canvas(ratio, short_edge):
"""comfy_extras.nodes_minimax_h3.adapt_canvas, with the short edge a knob.
Line for line the same arithmetic as core, which is the point: pin the short
edge, derive the long edge from the ratio, scale down if the area cap is
exceeded, round each axis to the nearest 32. Core hard-codes BASE_SHORT_EDGE
= 768; this takes it as an argument so the draft tiers below native run the
identical path rather than a second implementation that agrees with it only
at one rung.
v1.1 briefly derived both axes from a megapixel budget instead. That is a
different algorithm wearing the same rounding, and it disagreed with core at
EVERY aspect ratio -- 16:9 came out 1312x736 against core's 1344x768, and
4:3 came out 1152x864 against 1024x768. The tell was in core's own docstring
("768-short-edge canvas with 768*1344 area cap") the whole time.
"""
short = max(CANVAS_MULTIPLE, int(short_edge))
if ratio >= 1.0:
w, h = short * ratio, float(short)
else:
w, h = float(short), short / ratio
if w * h > CANVAS_AREA_CAP:
scale = math.sqrt(CANVAS_AREA_CAP / (w * h))
w, h = w * scale, h * scale
m = CANVAS_MULTIPLE
return (max(m, int(round(w / m)) * m), max(m, int(round(h / m)) * m))
def _canvas(resolution, aspect):
"""(width, height) for a resolution label and an aspect label.
Three paths, in order. A 1.0.x label is pinned to the exact tuple it shipped
with. A v1.1 pre-release label is aliased onto the tier it was trying to
name. Anything current goes through core's arithmetic.
An unreadable label is worth a line of output: before 1.1 this returned
1280x736 for any unrecognised input and said nothing, so a typo in an
API-driven graph rendered at the wrong size with no evidence anywhere.
"""
res, asp = str(resolution), str(aspect)
if (res, asp) in LEGACY_CANVAS:
w, h = LEGACY_CANVAS[(res, asp)]
print(f"[{TAG}] resolution {res!r} is a 1.0.x label: holding {w}x{h} so "
f"this workflow keeps the pixels it was built with. Choose a "
f"current resolution to move onto the 768p tier ladder.",
flush=True)
return w, h
if res in LEGACY_ALIAS:
moved = LEGACY_ALIAS[res]
print(f"[{TAG}] resolution {res!r} was a v1.1 pre-release label and its "
f"sizes were wrong; reading it as {moved!r}.", flush=True)
res = moved
ratio = ASPECTS.get(asp)
if ratio is None:
print(f"[{TAG}] unknown aspect {asp!r}; using {DEFAULT_ASPECT}",
flush=True)
ratio = ASPECTS[DEFAULT_ASPECT]
short = RESOLUTIONS.get(res)
if short is None:
short = RESOLUTIONS[DEFAULT_RESOLUTION]
print(f"[{TAG}] unknown resolution {resolution!r}; using "
f"{DEFAULT_RESOLUTION}", flush=True)
return _fit_canvas(ratio[0] / ratio[1], short)
def _duration_frames(duration):
return int(DURATION_FRAMES.get(str(duration), 243))
def _overlap_frames(overlap):
key = str(overlap)
if key in OVERLAP_FRAMES:
return OVERLAP_FRAMES[key]
return int(overlap)
# Prompt phrasing rule: AFFIRMATIVE ONLY.
# Sampling runs through BasicGuider at cfg 1.0 with no negative branch, so every
# concept named in the prompt is additive and cannot be subtracted -- "Do not
# restart the scene" puts `restart` in front of the encoder. State what the shot
# IS doing, never what it must not do. Keep this rule when editing below.
CONTINUE_PREFIX = (
"The clip opens on the action already in progress from the pinned frames. "
"The same people continue from where the pinned frames leave off, in the same "
"wardrobe, the same room, and the same lighting. "
"After a brief hold, the action carries forward from its current point.\n\n"
)
ADVANCE_BEAT = (
"The action already in progress carries forward from its current point."
)
MAX_REF_VIDEOS = 3
def _result(out):
if hasattr(out, "args"):
return out.args
if isinstance(out, (tuple, list)):
return tuple(out)
return (out,)
def _model_fingerprint(model):
"""Identify the incoming MODEL by what has been patched onto it.
The hop cache has to notice when a hop was rendered under a different LoRA
stack or a different attention path, or it will happily serve frames that
do not belong to the current graph -- silently wrong output, which is worse
than no cache at all. When the patch nodes were a widget on this node the
parsed plan went into the key directly; with them drawn upstream the only
thing available is the ModelPatcher itself.
Cheap and content-derived: the set of weight keys any LoRA touched plus the
per-key strength scalars, and the scalar half of `transformer_options`,
which is where the SLA and low-VRAM attention overrides land. Patch *values*
are tensors and are deliberately not hashed.
`patches_uuid` is not usable here: `ModelPatcher.add_patches` assigns a
fresh `uuid4()` on every call, so it would change every run and bust the
cache even when nothing about the graph moved.
Known collision: two different LoRAs touching an identical key set at
identical strengths fingerprint the same. Rare, and the alternative costs a
full state-dict walk per run.
"""
h = hashlib.sha256()
# The base checkpoint itself. Everything below describes what was PATCHED
# onto the model and nothing identified the model underneath it, so an int8
# build and a bf16 build of the same architecture, under the same LoRA
# stack at the same strengths and the same attention settings, produced
# byte-identical hop keys -- and the cache served frames rendered under the
# other checkpoint. `model_dtype()` is ModelPatcher's own accessor
# (comfy/model_patcher.py); it returns None when the inner model has no
# `get_dtype`, which is hashed as a value rather than skipped so "no dtype"
# and "some dtype" cannot collide.
#
# Residual gap, narrower than the one it closes: two *different* int8
# builds of the same architecture still match. Separating those needs
# digests of a few fixed weight keys, which costs a state-dict walk per run.
base = getattr(model, "model", None)
try:
base_dtype = model.model_dtype() if hasattr(model, "model_dtype") else None
except Exception: # noqa: BLE001 -- a patcher that cannot answer is still a key
base_dtype = "?"
h.update(f"base:{type(base).__name__}:{base_dtype}".encode())
_dm = getattr(base, "diffusion_model", None)
if _dm is not None:
try:
h.update(f":n{sum(p.numel() for p in _dm.parameters()):d}".encode())
except Exception: # noqa: BLE001
h.update(b":n?")
patches = getattr(model, "patches", None) or {}
for key in sorted(patches):
h.update(str(key).encode())
for entry in patches[key]:
# (strength_patch, weights, strength_model, offset, function)
try:
h.update(f"{float(entry[0]):.6g}".encode())
if len(entry) > 2 and isinstance(entry[2], (int, float)):
h.update(f"{float(entry[2]):.6g}".encode())
except (TypeError, ValueError, IndexError):
h.update(b"?")
opts = getattr(model, "model_options", None) or {}
transformer = opts.get("transformer_options") or {}
def _closure_scalars(fn):
"""The scalar settings a callable closed over.
H3-SLA-Attention installs its config by closure --
`_make_override(state, float(sparsity_ratio), blkq, blkk,
int(min_seq_len), bool(protect_audio))` -- so a callable rendered as
`type(fn).__name__` hashes to the bare string "function" and SLA's
settings vanish from the key. Changing sparsity 0.90 -> 0.50 then left
the fingerprint unmoved and the cache served hops rendered under a
different attention path.
Scalars only, deliberately: the first cell is a mutable `state` dict the
sampler counts into during the run, and hashing that would change the
fingerprint on every queue and never hit the cache at all.
"""
parts = [getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "")]
for cell in (getattr(fn, "__closure__", None) or ()):
try:
v = cell.cell_contents
except ValueError: # empty cell, e.g. a recursive closure
parts.append("?")
continue
parts.append(repr(v)
if isinstance(v, (str, int, float, bool)) or v is None
else type(v).__name__)
return "fn(" + ",".join(parts) + ")"
def _object_scalars(obj):
"""Public scalar attributes of something that configures itself by
instance rather than by closure.
`_closure_scalars` digs settings out of a callable's cells, which is how
H3-SLA-Attention carries its config. A node that installs a configured
*object* instead -- `set_model_patch_replace(cache, "dit", "block_loop",
0)` with an instance on it -- has no closure at all, and an instance
inherits neither `__qualname__` nor `__name__` from its class, so it
rendered as the bare constant "fn()" and every setting on it vanished
from the key. Toggling such a node moved the fingerprint (a new key
appears in `patches_replace`); changing its settings did not. That is
the SLA bug one type away.
Scalars only, for the same reason the closure walk is scalars only.
Note the tradeoff this accepts: a scalar attribute the node mutates
during a run makes the fingerprint move between runs and the cache stop
hitting while that node is installed. That direction is deliberate --
this pack treats serving frames from the wrong settings as worse than
not serving them at all.
"""
try:
items = vars(obj).items()
except TypeError: # no __dict__ (slots, builtins) -- nothing to read
return ""
parts = [f"{k}={v!r}" for k, v in sorted(items, key=lambda kv: str(kv[0]))
if not str(k).startswith("_")
and (isinstance(v, (str, int, float, bool)) or v is None)]
return "{" + ",".join(parts) + "}" if parts else ""
def _callable_scalars(fn, depth=0):
"""Settings a callable carries, whichever way it carries them.
There are four ways a node hands a configured callable to the model and
all four have to reach the hash, because they are interchangeable from
the installing node's point of view and indistinguishable from here:
* a closure -- cells (`_closure_scalars`)
* a configured instance -- its attributes (`_object_scalars`)
* a BOUND METHOD of a configured instance -- neither. `vars()` on a
bound method proxies to the underlying *function's* `__dict__`,
which is empty, so the instance's settings were invisible; only
`__qualname__` survived. A node registering `self.forward` rather
than `self` is the object case one attribute away.
* a `functools.partial` -- neither either. It has no `__name__`, no
`__qualname__`, no `__closure__`, and an empty `__dict__`, so it
collapsed to the constant "fn()" exactly as a bare instance did.
Everything it carries is in `func`, `args` and `keywords`.
Depth-bounded because `partial` can wrap `partial`.
"""
parts = [_closure_scalars(fn), _object_scalars(fn)]
if depth <= 3:
owner = getattr(fn, "__self__", None)
if owner is not None:
parts.append("@" + type(owner).__name__ + _object_scalars(owner))
inner = getattr(fn, "func", None)
if inner is not None and callable(inner):
bound = ["<" + _callable_scalars(inner, depth + 1)]
for a in (getattr(fn, "args", None) or ()):
bound.append(repr(a) if isinstance(a, (str, int, float, bool)) or a is None
else type(a).__name__)
kw = getattr(fn, "keywords", None) or {}
for k in sorted(kw, key=str):
v = kw[k]
bound.append(f"{k}=" + (repr(v)
if isinstance(v, (str, int, float, bool)) or v is None
else type(v).__name__))
parts.append(",".join(bound) + ">")
return "".join(parts)
def _scalars(obj, depth=0):
"""Only names and scalars -- tensors and mutable state are not stable."""
if depth > 3:
return "..."
if isinstance(obj, dict):
# sorted(obj, key=str), not sorted(map(str, obj)): stringifying the
# keys first drops every non-str key from the hash, because the
# `k in obj` guard then fails against the real key.
return "{" + ",".join(
f"{k}:{_scalars(obj[k], depth + 1)}" for k in sorted(obj, key=str)
) + "}"
if isinstance(obj, (list, tuple)):
return "[" + ",".join(_scalars(v, depth + 1) for v in obj) + "]"
if isinstance(obj, (str, int, float, bool)) or obj is None:
return repr(obj)
if callable(obj):
return _callable_scalars(obj)
return type(obj).__name__ + _object_scalars(obj)
h.update(_scalars(transformer).encode())
return h.hexdigest()[:16]
def _parse_shots(text):
text = (text or "").strip()
if not text:
raise ValueError(f"{TAG}: prompt is empty")
if text.startswith("{") or text.startswith("["):
try:
data = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"{TAG}: prompt looks like JSON but does not parse ({e})") from e
if isinstance(data, dict):
shots = [str(p).strip() for p in data.get("prompts", []) if str(p).strip()]
elif isinstance(data, list):
shots = [str(p).strip() for p in data if str(p).strip()]
else:
shots = []
if shots:
return shots
parts = [b.strip() for b in re.split(r"(?m)^---\s*$", text) if b.strip()]
return parts or [text]
def _parse_state(text):
"""continuity_state input: blank -> no-op, else a JSON object (from HTCContinuityState)."""
text = (text or "").strip()
if not text:
return {}
try:
data = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"{TAG}: continuity_state looks like JSON but does not parse ({e})") from e
if not isinstance(data, dict):
raise ValueError(f"{TAG}: continuity_state must be a JSON object")
return data
def _continue_prompt(block):
"""Wrap a verbatim-mode block as a continuation.
Never rewrite the official summary task types here. This used to replace
`[keyframe completion]` with `[video continuation + reference generation]`,
which is what made hop 2 of chain_00030/00031 a new stills generate instead
of a first-frame continue. Combine types with ` + `; never drop one that is
already present.
"""
text = (block or "").strip()
if _d.is_full_h3_prompt(text):
text = _d.flatten_official_continue(text)
return CONTINUE_PREFIX + (text or ADVANCE_BEAT)
def _expand_shots(blocks, chains, hop_script="verbatim"):
original = list(blocks)
if len(blocks) > chains:
print(f"[{TAG}] dropping {len(blocks) - chains} extra --- block(s)", flush=True)
blocks = blocks[:chains]
unique = len(original) if len(original) <= chains else chains
while len(blocks) < chains:
if hop_script == "next" and unique == 1:
blocks.append("")
else:
blocks.append(blocks[-1])
if hop_script == "next":
return blocks, unique
out = []
for i, block in enumerate(blocks):
wrap = i > 0 and (unique == 1 or i >= unique)
out.append(_continue_prompt(block) if wrap else block)
return out, unique
def _state_entry_text(entry, hop_index):
"""locked (verbatim) + context (current-state) + this hop's mutable beat."""
if not isinstance(entry, dict):
return ""
locked = str(entry.get("locked") or "").strip()
context = str(entry.get("context") or "").strip()
mutable = entry.get("mutable") or []
if isinstance(mutable, str):
mutable = _parse_shots(mutable) if mutable.strip() else []
mutable = [str(b).strip() for b in mutable if str(b).strip()]
beat = ""
if mutable:
idx = hop_index if hop_index < len(mutable) else len(mutable) - 1
beat = mutable[idx]
return "\n".join(p for p in (locked, context, beat) if p)
def _state_header(state, hop_index):
"""Compose the continuity_state block for this hop (locked/context every hop, mutable indexed)."""
if not state:
return ""
sections = []
setting_text = _state_entry_text(state.get("setting") or {}, hop_index)
if setting_text:
sections.append("setting:\n" + setting_text)
for char_id, entry in (state.get("characters") or {}).items():
char_text = _state_entry_text(entry, hop_index)
if char_text:
sections.append(f"character {char_id}:\n" + char_text)
return "\n\n".join(sections)
def _identity_lock(n_stills, live_picture, identity_ordinals=None, n_subjects=None):
"""Name the pictures that are people, and only those.
`identity_ordinals` comes from the reference register, which is the only
thing that knows a picture is a face rather than a room. Without it this
falls back to "every wired still is an identity", which is what it always
did and is right when nothing better is known -- but with a register wired
that fallback tells the encoder a photograph of a kitchen has a face and a
hairstyle to match exactly. At cfg 1.0 there is no negative branch, so that
is additive noise on every hop.
`n_subjects` drives number agreement, not the picture count: two
photographs of one person is still one identity.
"""
ords = (list(identity_ordinals) if identity_ordinals is not None
else list(range(1, int(n_stills) + 1)))
if not ords:
# A register with no subject-bearing refs -- setting plates only. There
# is no identity to lock, and _live_cite still cites the live frame.
return ""
pics = ", ".join(f"<Picture {i}>" for i in ords)
count = int(n_subjects) if n_subjects is not None else len(ords)
# Qwen3-VL is a language encoder, so number agreement is not cosmetic:
# "<Picture 1> are the only identities" is what a single wired ref produced.
if count == 1:
line = (
f"{pics} is the only identity. That face, bone structure, and hairstyle "
"match the photograph exactly."
) if len(ords) == 1 else (
f"{pics} are the same one person, and the only identity. That face, bone "
"structure, and hairstyle match those photographs exactly."
)
else:
line = (
f"{pics} are the only identities. Each face, bone structure, and hairstyle "
"matches its photograph exactly."
)
if live_picture and live_picture not in ords:
who = ("that same person as they stand" if count == 1
else "those same people as they stand")
line += f" <Picture {live_picture}> shows {who} right now, mid-action."
return line
def _refvid_cite(desc, ordinal=1):
"""One sentence saying what the author's reference clip is for.
The clip has always gone in as <Video 1> with nothing anywhere in the prompt
naming it -- the same uncited-reference problem the stills had on hops after
the first, and the same consequence: a Ref2VA model handed footage and no
reason for it tends to render the footage. `_live_cite` covers the PINNED
tail, which is a different video and already explained.
Empty desc returns "", so a workflow that does not fill the new field emits
byte-identical prompts to 1.0.x. The wording is the author's; only the
citation is ours.
"""
text = str(desc or "").strip().rstrip(".")
if not text:
return ""
return f"<Video {ordinal}> is a reference clip: {text}."
def _live_cite(live_picture, live_video):
bits = []
if live_picture:
bits.append(
f"<Picture {live_picture}> is the live frame at the start of this clip, "
"already in progress from the pinned tail."
)
if live_video:
bits.append(
f"<Video {live_video}> is the pinned tail of the previous clip and the first "
"moments of this generate. The motion continues at the speed it already has."
)
return " ".join(bits)
def _assemble_next(beat, live_picture=None, live_video=None,
n_stills=0, state_header="",
identity_ordinals=None, n_subjects=None, tail=None,
continuity="", retention="", wardrobe=False, refvid=""):
"""Hop 2+ in `next` mode: user text is only the new beat."""
text = (beat or "").strip() or ADVANCE_BEAT
cite = _live_cite(live_picture, live_video)
lock = _identity_lock(n_stills, live_picture,
identity_ordinals=identity_ordinals,
n_subjects=n_subjects)
solo = (n_subjects == 1) or (n_subjects is None and identity_ordinals is not None
and len(identity_ordinals) == 1)
whoever = "The same person holds" if solo else "The same people hold"
if _d.is_full_h3_prompt(text):
print(
f"[{TAG}] hop 2+ full H3 block flattened to a continuation beat "
"(a complete Ref2VA prompt on hop 2+ starts a new scene)",
flush=True,
)
text = _d.flatten_official_continue(text) or ADVANCE_BEAT
# No identity header on hop 2+ at all: register subject_prose is a
# subject_definitions block, and putting one here made hop 2 a second
# Ref2VA generate (chain_00033, the short drink beat). The call site passes
# nothing, and there is no parameter to pass.
header = str(state_header or "").strip()
top = header + "\n\n" if header else ""
# Labelled blocks lead, prose follows -- the same shape hop 1 gets from
# subject_prose. Deliberately NOT folded into `inject`: that is space-joined
# into a single paragraph, and retention_analysis is a multi-line block that
# carries its own label. Only retention, never subject_definitions: a
# <Subject N> introduced on hop 4 has no antecedent in its own encode, which
# is the dangling-token defect the call site documents.
ret_block = str(retention or "").strip()
ret_block = (ret_block + "\n\n") if ret_block else ""
# Order: who the pictures are, then what stays the same, then which
# frame is live. `continuity` carries no ordinals, so it is safe on a
# pin-only hop where `lock` is deliberately empty.
inject = " ".join(p for p in (lock, str(continuity or "").strip(),
str(refvid or "").strip(), cite) if p)
inject = (inject + "\n\n") if inject else "\n"
# Pin-only hops have no identity photographs. Naming them (chain_00034)
# sent the encoder back to the face/outfit stills — commercial kitchen,
# grey shirt, no apron.
# The closer used to end on "still underway" unconditionally, which made it
# the last sentence of the prompt and so overrode the shot's own `tail`
# directive -- `settle` and `hold` were unreachable on hop 2+. On the final
# hop nothing absorbs that instruction and the model invents late action to
# satisfy it (observed: a line of dialogue in the last second of a 3-hop
# chain, and the ~23 s spike noted in CLAUDE.md). Each variant below still
# ends on the clip's terminal *state*, never on the photographs, so the
# ordering rule documented at the return is preserved.
terminal = {
"settle": "and that action eases to a rest and stays there through the "
"final frames.",
"hold": "and the final position holds steady through the last moments.",
}.get(str(tail or "").strip(), "and that action is still underway as the clip ends.")
# "Clothing follows the live frame" and a wardrobe plate scheduled onto this
# hop are contradictory instructions, and at cfg 1.0 both are additive --
# there is no negative branch to resolve them, so the encoder gets each with
# equal weight. When a `partially_copy` still is actually here, it wins;
# otherwise the live frame does, exactly as before.
clothing = (
"Clothing follows the wardrobe photograph, worn on the body as it "
"already stands. " if wardrobe else
"Clothing follows whatever is already on them in the live frame. "
)
if lock:
hold = (
f"{whoever} their current pose, room, lighting, and camera side, "
"and the shot continues from exactly there. "
)
closer = (
"Faces and hair follow the identity photographs. "
f"{clothing}"
"After a brief hold on the incoming action, the shot advances "
f"through what the next-beat describes, {terminal}"
)
else:
hold = (
"The incoming frame holds the current pose, room, lighting, and "
"camera side, and the shot continues from exactly there. "
)
closer = (
(f"{clothing}Room and lighting stay as they are in the live frame. "
if wardrobe else
"Wardrobe, room, and lighting stay as they are in the live frame. ")
+ "After a brief hold on the incoming action, the shot advances "
+ f"through what the next-beat describes, {terminal}"
)
# Official field names on hop 2+ start a new Ref2VA generate
# (chain_00030..00034). One paragraph: airlock, then the beat.
text = re.sub(
r"(?m)^(overall_soundscape|non_diegetic_music):\s*", "", text).strip()
return (
f"{top}"
f"{ret_block}"
"The clip opens already in progress from the pinned frames. "
"The incoming arrangement holds for a short beat -- breath, a weight "
"shift, an eyeline -- and only then the next action begins. "
f"{hold}"
f"{inject}"
"What happens next:\n"
f"{text}\n\n"
# Ordering rule: the prompt must END on ongoing motion, never on the
# photographs. The final sentence governs the terminal state of the clip,
# and when the beat action finishes before the frames run out the model
# renders whatever the prompt last pointed it at. Ending on "follow the
# identity photographs" made the tail settle onto the reference image --
# observed as the last ~3s of a 3-hop chain cutting to the ref still.
# Hops 1..N-1 hide this because the pin consumes their tail; the final
# hop has no successor, so its drift is what you see.
f"{closer}"
)
def _attach_pin_to_qwen(pin_mode, hop_images, hop_videos, last_frame, pin_clip):
"""AddGuide is invisible to Qwen. Optionally put the incoming state in ref slots.
The live frame is <Picture 1>. Identity stills shift up. Appending it after
the stills (chain_00034) made the pin Picture 4 against a commercial-kitchen
face and outfit as Pictures 1–2; hop 2 hard-cut and dropped the apron.
"""
images = dict(hop_images or {})
videos = dict(hop_videos or {})
live_p = live_v = None
if pin_mode in ("last frame", "both") and last_frame is not None:
used = len(images)
if used >= MAX_REF_IMAGES:
print(f"[{TAG}] pin_to_qwen last frame skipped: already {MAX_REF_IMAGES} stills",
flush=True)
else:
# Insertion order IS Picture order (core walks .values()).
# Put the live frame in the dict first or it becomes the last picture.
ordered = {"ref_image_1": last_frame[:1].contiguous()}
for key, tensor in images.items():
n = int(str(key).rsplit("_", 1)[-1])
ordered[f"ref_image_{n + 1}"] = tensor
images = ordered
live_p = 1
extra = f", {used} still(s) -> Picture 2+" if used else ""
print(f"[{TAG}] Qwen last frame -> <Picture 1>{extra}", flush=True)
if pin_mode in ("pin clip", "both") and pin_clip is not None:
if pin_clip.shape[0] < 5:
print(f"[{TAG}] pin clip too short for a video ref "
f"({int(pin_clip.shape[0])}f)", flush=True)
elif len(videos) >= MAX_REF_VIDEOS:
print(f"[{TAG}] pin_to_qwen pin clip skipped: already {MAX_REF_VIDEOS} videos",
flush=True)
else:
live_v = len(videos) + 1
videos[f"ref_video_{live_v}"] = pin_clip.contiguous()
print(
f"[{TAG}] Qwen pin clip -> <Video {live_v}> "
f"({int(pin_clip.shape[0])}f, no soundtrack — voice stays <Audio 1>)",
flush=True,
)
return images, live_p, videos, live_v
def _latent_cpu(lat):
"""Keep the previous hop's sampler output off GPU between hops."""
if not isinstance(lat, dict) or "samples" not in lat:
return lat
out = dict(lat)
samples = lat["samples"]
try:
out["samples"] = samples.cpu()
except Exception as e:
# Falling back to the GPU tensor is correct -- the pin still works --
# but it is also a per-hop VRAM leak, so it must not be silent.
print(f"[{TAG}] could not move the hop latent to CPU ({e!r}); "
f"keeping it on device", flush=True)
out["samples"] = samples
return out
def _motion_context_cls():
"""Upstream MiniMaxH3MotionContext, skipping forks with a different apply()."""
try:
import inspect
import nodes as nodes_mod
except Exception:
return None
cls = getattr(nodes_mod, "NODE_CLASS_MAPPINGS", {}).get("MiniMaxH3MotionContext")
def compatible(c):
try:
params = inspect.signature(c.apply).parameters
need = [k for k, v in params.items()
if v.default is inspect.Parameter.empty and k != "self"]
return "context_frames" not in need
except Exception:
return False
if cls is not None and compatible(cls):
return cls
import sys
for mod in list(sys.modules.values()):
cand = getattr(mod, "MiniMaxH3MotionContext", None)
if cand is not None and compatible(cand):
if cls is not None:
print(
f"[{TAG}] MiniMaxH3MotionContext registry entry is a fork; "
f"using upstream class from {getattr(mod, '__name__', '?')}",
flush=True,
)
return cand
return None
def _latent_parts(x):
"""-> list of component tensors, or None. See latents.parts for the why."""
return _latents.parts(x)
def _rebuild_latent_samples(x, parts):
"""Put conditioned components back into the container they came from."""
return _latents.rebuild(x, parts)
def _condition_pin_latent(lat, anchor, mode="off", noise=0.0, seed=0):
"""Anti-ratchet preprocessing for the latent handed to Motion-Context.
MiniMaxH3MotionContext.apply() takes `context_latent` as-is and exposes no
hook, so every lever has to be applied to the latent before it goes in.
Two rescale modes, and the difference between them is the whole point.
`sigma` rescales the pin so its standard deviation matches the anchor hop's.
This is the original lever and **it is measurably the wrong statistic.** On
a 3-hop chain the pin's total sigma FELL (1.0414 -> 1.0289) while the
picture's mid-band energy climbed 8% and its high-band fraction rose 1.6%.
Matching sigma there scales the whole latent UP by 1.2%, lifting a high
band that was already too hot. Kept because it is what shipped, and old
workflows say "on".
`band` splits each spatial component into low and high and rescales only the
high part, so the *ratio* between them returns to the anchor hop's. That
ratio is what the ratchet actually moves. Still a scalar per band, so it
moves no structure and cannot blur or invent detail -- the property that
made `sigma` safe to run blind, kept.
`noise` mixes in a seeded perturbation, attacking the same ratchet from the
other side; measured gains reverse above 0.10, hence the widget cap.
**Per component, not per latent** (fixed 2026-08-27). Video and audio are
two tensors in one NestedTensor and their statistics drift independently, so
each carries its own anchor. Before this, `.std()` raised on the nested
object and every lever was dead -- announced once per hop as `pin
conditioning skipped`, which read as routine noise.
Returns `(latent, anchor)` where `anchor` is a list, one dict per component
-- the first pinned hop establishes what later hops are matched against.
Every lever defaults off, in which case the latent is returned untouched.
"""
if not isinstance(lat, dict) or "samples" not in lat:
return lat, anchor
x = lat["samples"]
parts = _latent_parts(x)
if parts is None:
print(f"[{TAG}] pin conditioning skipped: unrecognised latent "
f"({type(x).__name__})", flush=True)
return lat, anchor
mode = str(mode)
if mode == "on": # pre-2026-09-01 workflows
mode = "sigma"
try:
cur = []
for t in parts:
sig = float(t.float().std())
cur.append({"sigma": sig, "ratio": _latents.band_ratio(t)})
except Exception as e: # noqa: BLE001
print(f"[{TAG}] pin conditioning skipped ({e!r})", flush=True)
return lat, anchor
if not all(c["sigma"] == c["sigma"] and c["sigma"] for c in cur):
return lat, anchor # zero or NaN in any stream
if anchor is None:
anchor = cur
if len(anchor) != len(cur):
# Stream count changed mid-chain. Nothing sensible to match against.
print(f"[{TAG}] pin conditioning skipped: latent has {len(cur)} "
f"component(s), anchor has {len(anchor)}", flush=True)
return lat, anchor
# Always report the drift, even with every lever off. This is the number
# that says whether a lever is needed and whether one worked, and it costs
# nothing to read -- the alternative is inferring it from the master after
# a decode, which is how the wrong statistic went unnoticed for a release.
for i, (c, a) in enumerate(zip(cur, anchor)):
if c["ratio"] is not None and a["ratio"]:
print(f"[{TAG}] pin drift[{i}]: sigma {c['sigma']:.4f} "
f"(x{c['sigma'] / a['sigma']:.4f} vs anchor) "
f"high-band fraction {c['ratio']:.4f} "
f"(x{c['ratio'] / a['ratio']:.4f})", flush=True)
if mode not in ("sigma", "band") and noise <= 0.0:
return lat, anchor
out_parts, notes = [], []
for idx, (t, c, a) in enumerate(zip(parts, cur, anchor)):
o = t
if mode == "sigma":
scale = a["sigma"] / c["sigma"]
o = o * scale
notes.append(f"sigma[{idx}] x{scale:.4f}")
elif mode == "band":
o, k = _latents.match_band(o, a["ratio"])
if k is None:
# An audio component has no bands; leaving it alone is correct,
# not a fallback -- `sigma` on it would be a different lever
# applied silently under this one's name.
notes.append(f"band[{idx}] skipped (no spatial extent)")
else:
notes.append(f"band[{idx}] hi x{k:.4f} "
f"(fraction {c['ratio']:.4f} -> {a['ratio']:.4f})")
if noise > 0.0:
# Per component: `.shape` on the nested object reports only the
# first component's shape, so one draw for the whole latent would
# size its noise to the video and broadcast that onto the audio.
g = torch.Generator(device="cpu").manual_seed((int(seed) + idx) & 0x7FFFFFFF)
n = torch.randn(o.shape, generator=g, dtype=torch.float32)
o = o + n.to(dtype=o.dtype, device=o.device) * (float(noise) * a["sigma"])
notes.append(f"noise[{idx}] {float(noise):.3f}")
out_parts.append(o)
if notes:
print(f"[{TAG}] pin conditioning: " + ", ".join(notes), flush=True)
new = dict(lat)
new["samples"] = _rebuild_latent_samples(x, out_parts)
return new, anchor
def _core_call(node_cls, what, **kw):
"""Call a Core node by keyword, and fail readably when Core has moved.
This pack is installed beside whatever ComfyUI the user already has, so
Core's signature is an external interface it does not control. Passing
arguments positionally made that fragile in a way that surfaced as a
baffling error on someone else's machine -- "got multiple values for
argument 'ref_image_size'" on hop 1, with nothing in the message to
suggest a version mismatch.
Keywords fix the misbinding. This adds the other half: if the installed
Core does not accept an argument this pack passes, say which node, which
argument, and what that Core actually takes, so the report names the
problem instead of a traceback.
"""
try:
return node_cls.execute(**kw)
except TypeError as e:
import inspect
try:
params = [p for p in inspect.signature(node_cls.execute).parameters
if p not in ("cls", "self")]
except (TypeError, ValueError):
params = None
raise RuntimeError(
f"{TAG}: this ComfyUI's {node_cls.__name__} does not accept the "
f"arguments this pack passes for {what} ({e}). "
+ (f"Its signature takes: {', '.join(params)}. " if params else "")
+ f"This pack passes: {', '.join(sorted(kw))}. "
"That is a ComfyUI/pack version mismatch -- update ComfyUI, or "
"report these two lists."
) from e
def _validate_anchors(shots, start_image_file):
"""Refuse an unusable anchor=restart before anything samples.
A pure function so it can be exercised: the three rules below shipped
twice broken -- once naming a `join` value that does not exist, once
reading `start_image` before it was assigned -- because nothing called
them except a real render.
"""
for i, sh in enumerate(shots or []):
if str((sh or {}).get("anchor") or "") != "restart":
continue
if i == 0:
raise ValueError(
f"{TAG}: shot 1 cannot be anchor=restart -- hop 1 is already "
"a chain start. Remove it, or move it to a later shot.")
# The FILENAME, not the loaded image: this runs before any media is
# loaded, which is the point of it. Whether the file resolves is a
# separate check further down.
if not str(start_image_file or "").strip():
raise ValueError(
f"{TAG}: shot {i + 1} is anchor=restart but no start image is "
"set. A restart re-anchors the chain on that photograph; "
"without one there is nothing to restart from. Set "
"start_image_file in MEDIA, or remove the anchor.")
if ((sh.get("directives") or {}).get("join")) == "continuous":
raise ValueError(
f"{TAG}: shot {i + 1} is anchor=restart with join=continuous. "
"A restart is a cut -- it opens on the start image's pose, not "
"the previous hop's last frame. Use join=hard_cut or match_cut "
"on that shot.")
def _validate_last_frame_guide(last_frame_guide, start_image_file):
"""Refuse last_frame_guide=still with no photograph, on the queue.
Same class as `_validate_anchors`: a guard that only a render would
otherwise exercise. Whitespace is not a file.
"""
if str(last_frame_guide) != "still":
return
if not str(start_image_file or "").strip():
raise ValueError(
f"{TAG}: last_frame_guide=still but no start image is set. That "
"mode pins start_image at the last pixel frame of every hop; "
"without one there is nothing to pin. Set start_image_file in "
"MEDIA, or leave last_frame_guide=off.")
def _guides_last_frame(mode, hop_index, shots):
"""Does THIS hop get the still pinned at its last frame? -> bool.
`still` guides every hop. `before_restart` guides only a hop whose
successor is `anchor="restart"`, which is the only place the guide has
been shown to earn its keep.
Measured, and the reason the third option exists. Guiding every hop turns
a restart from an obvious jump into a match cut: the four hop endings of a
4-hop chain converge to 3.8/255 of each other against 39.1/255 unguided,
while mid-hop frames stay as varied as ever (65.8 against 61.1). Two people
watched that clip in motion and could not see the convergence, because a
hop's last frame passes in a twenty-fourth of a second.
But it plants the photograph unconditionally, and a shot authored
`framing: close` therefore plays as a close-up and then snaps to the
still's wide framing in about 0.6 s at its own ending -- then the next hop
pushes back in and snaps again. Watched without prompting, that reads as
"the camera kept cutting in and out". The directive wins the middle of the
hop and the guide wins the end, which is the worst division of the two.
`before_restart` keeps the match cut and drops the pumping everywhere else.
"""
mode = str(mode or "off")
if mode == "off":
return False
if mode == "still":
return True
if mode != "before_restart":
return False
nxt = shots[hop_index + 1] if hop_index + 1 < len(shots) else None
return bool(nxt) and str((nxt or {}).get("anchor") or "") == "restart"
def _last_frame_guide_key_field(mode, hop_index, shots):
"""Per-hop cache field, or None so the key stays byte-identical when off.
Keyed on what this hop actually GETS, not on the widget: under
`before_restart` most hops are unguided and must keep the key they had
when the feature did not exist. Omitting the field when a hop is unguided
is the empty-string rule from master_audio_file -- a None or "off" field
would move every existing cache key.
"""
if not _guides_last_frame(mode, hop_index, shots):
return None
return str(mode)
def _voice_rides_hop(mode, block):
"""Whether the timbre clip stays cited as <Audio N> on a continuation.
`off` is the shipped behaviour -- hop 1 only. The reason is in the gate
below: a second <Audio 1> with no line to attach to put a 1.35 s male
take into the last second of chain_00038 while the written line still
followed the woman's face. That failure needs a QUIET hop, because what
the clip fills is frames nothing else was assigned. So `speaking` rides
every hop whose beat actually has a spoken line and skips the ones that
do not, which is the whole failure class the restriction was protecting
against -- and it is the setting to recommend. `on` rides every hop
unconditionally, for a chain where every hop talks and the author would
rather own that risk than annotate it.
Two dialogue forms count, because both are authored in the wild: the
single-quoted line the example plans and the writer use ("she says,
'You are early.'"), and the official `<d>[English] ...</d>` tag from the
H3 contract. The quoted-line test is `planner.spoken_spans`, not a
second regex -- the delimiter rule (an apostrophe between two
alphanumerics is not a quote) already lives there and a copy would
drift. Checking only one form would silently strand half the users on
hop-1-only while the widget said otherwise.
"""
m = str(mode or "off")
if m == "on":
return True
if m != "speaking":
return False
text = str(block or "")
return bool("<d>" in text or _planner.spoken_spans(text))
def _last_pixel_guide_idx():
"""AddGuide frame_idx for the last pixel frame of this hop.
AddGuide's index is PIXEL frames, not latent tokens. Core treats a
negative value as counted from the end, so -1 is the last pixel frame
regardless of this hop's duration.
Do not pass latent_T-1. FRAME_PER_TOKEN is (1, 4, 4, 4, 4); on an 8 s
hop (192 px frames, latent T=57) that index is pixel 56 -- about 2.3 s
in -- not the end. That is the bug this helper exists to stop.
"""
return -1
# Above this, the master frame buffer is spilled to disk instead of RAM. The
# number is a judgement, not a measurement: below it the mapping buys nothing
# worth the I/O, and above it the buffer is competing with the DiT and the VAE
# for the length of a run during which nothing reads it.
MASTER_SPILL_BYTES = 2 << 30 # 2 GiB
# The master is DELIVERY-ONLY: `prev_imgs` is cloned from `imgs`, never read
# back out of the master, so nothing in the conditioning path depends on its
# precision. fp16 halves both the footprint and the disk I/O for free.
#
# fp16 and not bf16, because the master is clamped to 0..1 so exponent range
# buys nothing and mantissa bits are the whole question. fp16's ten mantissa
# bits space the top octave at ~1/2048, about eight times finer than the 8-bit
# encode downstream; bf16's seven space it at 1/256 -- exactly 8-bit output
# precision with nothing in reserve, and highlights would band.
#
# Two things make it safer than it first looks. `tone_compensate` runs BEFORE
# the master write, so all correction arithmetic stays in fp32 and the
# quantisation never compounds with it. And the anchor mode's failure direction
# is crushed blacks, which is fp16's strongest region.
#
# Emphatically NOT true of the hop cache, where CLAUDE.md's 16-bit reasoning is
# load-bearing: a cached hop's last frame becomes the next hop's pin, so a
# lossy round trip there would make a resumed chain diverge from an
# uninterrupted one. Same-looking decision, opposite answer, different tensor.
MASTER_DTYPE = torch.float16
MASTER_NP_DTYPE = np.float16
def _open_self_deleting(path, nbytes):
"""A file sized to `nbytes` that removes itself when its last handle closes.
The first version of this spilled to an ordinary file and swept stale ones
on the next run. That was wrong, and measurably so: ComfyUI holds the
previous run's IMAGE output in its execution cache, so the mapping is still
open when the next run starts, `os.remove` raises, and the sweep skipped it
-- silently, because the handler passed on OSError. Two renders left two
9 GB files behind. The docstring claimed "there is never more than one".
Delete-on-close removes the whole problem instead of policing it. Windows
has it natively as `O_TEMPORARY`; POSIX gets the same behaviour by
unlinking immediately while the descriptor stays open. Either way the
bytes live exactly as long as something is using them, the file never
appears in a listing after that, and a crashed process cleans up on exit
because the kernel closes its handles.
"""
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
flags |= getattr(os, "O_TEMPORARY", 0)
fh = os.fdopen(os.open(path, flags), "r+b")
try:
fh.truncate(int(nbytes))
if not hasattr(os, "O_TEMPORARY"):
os.unlink(path) # POSIX: the inode outlives the name
except Exception:
fh.close()
raise
return fh
def _sweep_master_spills(keep):
"""Remove spill files a previous BUILD or a hard kill left behind.
Self-deleting files make this a safety net rather than the mechanism: it
exists for files written by the version of this code that did not use
delete-on-close, and for anything a `kill -9` orphaned before the handle
was open. It should normally find nothing.
A file that cannot be removed is reported rather than swallowed. Silence is
what let 18 GB accumulate unnoticed the first time.
"""
import folder_paths
root = folder_paths.get_temp_directory()
freed = stuck = 0
try:
names = os.listdir(root)
except OSError:
return
for name in names:
if not (name.startswith("htc_master_") and name.endswith(".raw")):
continue
path = os.path.join(root, name)
if path == keep:
continue
try:
n = os.path.getsize(path)
os.remove(path)
freed += n
except OSError:
try:
stuck += os.path.getsize(path)
except OSError:
pass # gone between the listing and here
if freed:
print(f"[{TAG}] reclaimed {freed / 2**30:.1f} GB from an earlier "
f"master spill", flush=True)
if stuck:
print(f"[{TAG}] {stuck / 2**30:.1f} GB of old master spills could not "
f"be removed (still mapped by this process). They clear when "
f"ComfyUI restarts; temp/ is wiped at startup.", flush=True)
def _alloc_master(total_frames, height, width):
"""The master frame buffer, in RAM or memory-mapped to disk.
It is allocated once at full chain length, slice-written as each hop lands,
and then not read again until the final preview frame and the return. At
8 x 15 s and 1280x736 that is ~14.4 GB in fp16 -- it was ~29 GB in fp32 --
resident and inactive through every sampling pass, competing with the DiT,
the VAE decode buffers, `imgs` and `prev_imgs`.
**This does not save 14.4 GB.** ComfyUI's IMAGE type is a dense tensor, so the
whole master still has to exist to be returned. What moves is the peak:
from `master + inference` to `max(master, inference)`. On the chains where
this bites that is the difference between finishing and an OOM, and it is
not a saving -- do not write it up as one.
A `np.memmap` is the right primitive rather than an incremental writer: one
tensor, shape known up front, written in contiguous ranges in order. The
problem was first put to us in these terms by silveroxides, who proposed
the streaming writer from `unifiedefficientloader` (MIT); the diagnosis was
right and the writer was the wrong shape for one tensor of known size, so
no code travelled -- but the reading did, and the credit belongs here. Every
slice-write below is unchanged, and SaveVideo walking frames in order is
ideal page locality on the way back out.
The honest weakness: the OS decides when pages leave RAM. Under no memory
pressure they simply stay and nothing has been bought; under heavy pressure
a large dirty flush can stall at an awkward moment. Which path ran is
printed either way -- a silent memory path is the one thing nobody could
diagnose from a bug report.
"""
shape = (int(total_frames), int(height), int(width), 3)
nbytes = int(np.dtype(MASTER_NP_DTYPE).itemsize)
for d in shape:
nbytes *= d
gb = nbytes / 2**30
if nbytes < MASTER_SPILL_BYTES:
print(f"[{TAG}] master buffer: {gb:.1f} GB in RAM", flush=True)
return torch.empty(shape, dtype=MASTER_DTYPE)
try:
import folder_paths
root = folder_paths.get_temp_directory()
os.makedirs(root, exist_ok=True)
path = os.path.join(root, f"htc_master_{uuid.uuid4().hex}.raw")
_sweep_master_spills(path)
fh = _open_self_deleting(path, nbytes)
arr = np.memmap(fh, dtype=MASTER_NP_DTYPE, mode="r+", shape=shape)
out = torch.from_numpy(arr)
# Name the owner. The mapping is already kept alive by the tensor's
# storage, so this changes no lifetime -- it gives anything that needs
# to release the file deterministically (the offline checker today, an
# explicit writer if the memmap ever proves too passive) a handle
# instead of digging through gc. It does not survive the trim, which
# makes a view.
out._htc_mmap = arr
print(f"[{TAG}] master buffer: {gb:.1f} GB spilled to disk "
f"({os.path.basename(path)}, self-deleting)", flush=True)
return out
except Exception as e: # noqa: BLE001
# Falling back is correct -- a chain that renders slowly beats one that
# refuses to start because a temp directory is read-only.
# MASTER_DTYPE, not float32: `gb` was computed from
# MASTER_NP_DTYPE.itemsize, so a float32 buffer here would be twice
# what this line just told the user -- and it would land on the one
# machine that could not spare a spill file.
print(f"[{TAG}] master spill unavailable ({e!r}); {gb:.1f} GB in RAM",
flush=True)
return torch.empty(shape, dtype=MASTER_DTYPE)
def _dense_media(prefix, items):
"""Number the filled slots 1..N with no gaps. -> dict or None.
`<Video N>` and `<Audio N>` are POSITIONAL: core numbers reference blocks
by the order it iterates them, and the prompt cites those ordinals. A gap
would hand core `ref_video_1` and `ref_video_3`, and a beat written about
"the second clip" would then name something else. So slot 3 becomes
<Video 2> when slot 2 is empty, and the tooltips say so.
"""
out = {}
for x in items:
if x is not None:
out[f"{prefix}{len(out) + 1}"] = x
return out or None
def _pin_mech_for(hop_index, overlap_n, prev_sampled, mode="auto"):
"""Which mechanism `_pin_continue` will pick, without doing the work.
The hop cache key has to be built *before* the pin runs, and the two
mechanisms produce different frames, so the key needs the mechanism up
front. Every condition here mirrors _pin_continue; the one thing it cannot
predict is Motion-Context raising at call time, which the caller catches by
comparing this against the mechanism actually used and declining to cache
that hop.
`mode` is the `pin_mech` widget. "auto" is the shipped behaviour and the
four conditions below. Forcing does not add a fifth condition -- it removes
them, which is the point: a forced setting that quietly degrades to the
other mechanism tells you nothing, and the reason to force one is to
compare it against the other. The two chain-wide preconditions are
validated before any sampling starts, so the only one that can still be
false here is the per-hop latent.
"""
if hop_index == 0:
return "none"
if mode == "addguide":
return "addguide_pixels"
if mode == "motion_context":
if prev_sampled is None:
raise ValueError(
f"{TAG}: hop {hop_index + 1}: pin_mech=motion_context needs the "
"previous hop's sampler latent, and this one came from a cache "
"entry written before latents were stored. Re-render that hop "
"(edit it, or turn cache_hops off for one run) or use pin_mech="
"auto, which falls back to the AddGuide pixel pin here."
)
return "motion_context"
if _motion_context_cls() is None:
return "addguide_pixels"
if str(overlap_n) not in MC_CONTEXT_LENGTHS:
return "addguide_pixels"
if prev_sampled is None:
return "addguide_pixels"
return "motion_context"
def _pin_continue(cond, latent, vae, audio_vae, overlap_n,
prev_sampled, prev_imgs, prev_audio, audio_ctx=24,
mode="auto"):
"""Hop 2+ motion pin. Latent Motion-Context when possible; AddGuide otherwise.
AddGuide re-encodes decoded pixels and anchors audio forwards from frame 0
(cover-band soundtrack). Motion-Context slices the previous sampler AV
latent and end-aligns the audio window on this clip's timeline.
Returns `(conditioning, mech)` where mech is one of "motion_context",
"addguide_pixels" or "none". The caller puts mech in the *per-hop* cache
key: the two mechanisms produce different frames, so a hop rendered under
the AddGuide fallback must not be served later to a run where the latent
pin was available.
"""
ctx_label = str(overlap_n)
# `mode` mirrors _pin_mech_for. Both have to honour it or the mechanism in
# the per-hop key stops matching the one on disk, which is the failure that
# made cache_hops=on measurably worse than off before the latent sidecar
# landed. Do not change one of these without the other.
mc = None if mode == "addguide" else _motion_context_cls()
if mc is not None and ctx_label not in MC_CONTEXT_LENGTHS:
print(
f"[{TAG}] overlap {overlap_n}f has no Motion-Context context_length "
f"(accepts {sorted(MC_CONTEXT_LENGTHS, key=int)}); AddGuide pixel pin",
flush=True,
)
mc = None
if mc is not None and prev_sampled is not None:
try:
a_ctx = int(audio_ctx)
cond, trim = mc().apply(
conditioning=cond, vae=vae, latent=latent,
context_length=ctx_label, audio_context_length=a_ctx,
context_latent=prev_sampled,
)
print(
f"[{TAG}] Motion-Context pin: previous hop latent "
f"({ctx_label}f picture, {a_ctx}f audio, trim {trim})",
flush=True,
)
return cond, "motion_context"
except Exception as e:
# This downgrade changes the join, so it is logged with the real
# exception rather than swallowed -- repr(), because a bare
# TypeError from a renamed upstream kwarg stringifies to nothing
# useful.
if mode == "motion_context":
# Asked for explicitly, so falling back would answer a question
# nobody asked and quietly poison an A/B against the other
# mechanism.
raise RuntimeError(
f"{TAG}: pin_mech=motion_context but Motion-Context raised "
f"({e!r}). Use pin_mech=auto to fall back to the AddGuide "
"pixel pin."
) from e
print(
f"[{TAG}] Motion-Context pin failed ({e!r}); AddGuide pixel pin",
flush=True,
)
if mode == "addguide":
# Asked for. Saying "not available" here would be false, and it is the
# line a user would read while wondering why forcing it did nothing.
print(
f"[{TAG}] pin_mech=addguide: AddGuide pixel pin ({overlap_n}f)",
flush=True,
)
elif mc is None:
print(
f"[{TAG}] Motion-Context not available; AddGuide pixel pin "
f"({overlap_n}f). Install ComfyUI-H3-Motion-Context for a latent join.",
flush=True,
)
elif prev_sampled is None:
print(
f"[{TAG}] previous hop has no sampler latent (cache hit); "
f"AddGuide pixel pin ({overlap_n}f)",
flush=True,
)
pin_image = prev_imgs[-overlap_n:] if prev_imgs is not None else None
pin_audio = _tail_audio(prev_audio, overlap_n) if prev_audio is not None else None
if pin_image is None and pin_audio is None:
return cond, "none"
return _result(_core_call(
MiniMaxH3AddGuide, "the AddGuide pixel pin",
positive=cond, latent=latent, frame_idx=0,
vae=vae if pin_image is not None else None,
audio_vae=audio_vae if pin_audio is not None else None,
image=pin_image,
audio=pin_audio,
))[0], "addguide_pixels"
def _collect_ref_images(slot_images):
"""Dense-pack the wired slots into <Picture N> order.
Takes the slot -> tensor map the caller already built, rather than reading
the nine node inputs a second time. Gathering them twice -- once here, once
for `slot_images` -- is how a slot goes missing from one of the two and
silently renumbers every later <Picture N>, which is the exact failure
refs.py exists to prevent.
"""
frames = [slot_images[s] for s in sorted(slot_images)]
if not frames:
return None
bits = []
for i, im in enumerate(frames):
bits.append(f"<Picture {i + 1}> {int(im.shape[2])}x{int(im.shape[1])}")
print(f"[{TAG}] {len(frames)} reference image(s) -> " + ", ".join(bits), flush=True)
if len(frames) < 3:
print(f"[{TAG}] warning: fewer than 3 stills. A reference with no "
"picture chosen does not count.", flush=True)
return {f"ref_image_{i + 1}": frames[i] for i in range(len(frames))}
def _audio_samples(frames, sr):
return max(1, int(round(frames / float(FPS) * int(sr))))
def _tail_audio(audio, frames):
wav = audio["waveform"]
sr = int(audio["sample_rate"])
n = min(_audio_samples(frames, sr), int(wav.shape[-1]))
return {"waveform": wav[..., -n:].contiguous(), "sample_rate": sr}
def _trim_audio_head(audio, frames):
wav = audio["waveform"]
sr = int(audio["sample_rate"])
n = min(_audio_samples(frames, sr), int(wav.shape[-1]))
return {"waveform": wav[..., n:].contiguous(), "sample_rate": sr}, n
def _batch_wav(wav):
"""Comfy AUDIO is [B, C, T]. A squeezed take is [C, T]. Same rank, always.
The lock path used to leave hop 1's master as [C, T] and hop 2's trim as
[B, C, T]; `_xfade_audio` then died on `torch.cat` with 'got 2 and 3'.
GPU test 1 found it: hop 1 locked [0.00s-8.00s], hop 2 never wrote.
"""
if wav.dim() == 2:
return wav.unsqueeze(0)
if wav.dim() == 3:
return wav
raise ValueError(
f"{TAG}: waveform must be [C, T] or [B, C, T], got {tuple(wav.shape)}")
def _xfade_audio(left, right, sr, ms=40):
left = _batch_wav(left)
right = _batch_wav(right)
n = max(1, int(sr * ms / 1000.0))
k = min(n, int(left.shape[-1]), int(right.shape[-1]))
if k < 8:
return torch.cat([left, right], dim=-1)
t = torch.linspace(0, 1, k, dtype=left.dtype, device=left.device)
fade_out = torch.cos(t * math.pi / 2)
fade_in = torch.sin(t * math.pi / 2)
while fade_out.ndim < left.ndim:
fade_out = fade_out.unsqueeze(0)
fade_in = fade_in.unsqueeze(0)
seam = left[..., -k:] * fade_out + right[..., :k] * fade_in
return torch.cat([left[..., :-k], seam, right[..., k:]], dim=-1)
def _frame_to_jpeg_b64(frame, max_side=512, quality=80):
from PIL import Image
arr = (frame.detach().float().cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
if arr.ndim == 3 and arr.shape[-1] > 3:
arr = arr[..., :3]
img = Image.fromarray(arr)
w, h = img.size
scale = min(1.0, float(max_side) / float(max(w, h)))
if scale < 1.0:
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
buf = pyio.BytesIO()
img.save(buf, format="JPEG", quality=int(quality))
return base64.b64encode(buf.getvalue()).decode("ascii"), img.size
class _PreviewEncoder:
"""Encode preview JPEGs off the sampling thread.
Encoding inline cost the sampler a PIL resize plus a JPEG write at every
push. The queue is bounded and *drops* when full: a preview frame is worth
nothing if delivering it slows the render that produced it.
"""
def __init__(self, depth=2):
self._q = queue.Queue(maxsize=depth)
self._t = None
def _run(self):
while True:
job = self._q.get()
if job is None:
return
payload, frames = job
try:
for key, frame in frames.items():
b64, (w, h) = _frame_to_jpeg_b64(frame)
payload[key] = b64
if key == "image":
payload["w"], payload["h"] = w, h
except Exception as e:
print(f"[{TAG}] preview encode skipped: {e!r}", flush=True)
try:
PromptServer.instance.send_sync(
"h3_refchain_preview", payload, PromptServer.instance.client_id)
except Exception as e:
print(f"[{TAG}] preview send skipped: {e!r}", flush=True)
def submit(self, payload, frames):
if self._t is None:
self._t = threading.Thread(target=self._run, name="h3rc-preview",
daemon=True)
self._t.start()
try:
self._q.put_nowait((payload, frames))
except queue.Full:
pass # deliberate: never block the sampler for a preview
_PREVIEW = _PreviewEncoder()
def _push_preview(unique_id, status, frame=None, hop=0, total=0,
pin_mech=None, frac=None, seam_frame=None, meta=None):
"""Send one preview update.
`status` stays a SHORT label. The full per-hop prompt dump belongs on the
`info` output -- passing it here once turned the status strip into the
prompt. Everything structured goes in its own field instead, which is what
a panel can actually lay out.
"""
if not unique_id or PromptServer is None:
return
payload = {
"node_id": unique_id,
"status": status,
"hop": int(hop),
"total": int(total),
}
if pin_mech:
payload["pin_mech"] = str(pin_mech)
if frac is not None:
payload["frac"] = max(0.0, min(1.0, float(frac)))
if meta:
payload.update(meta)
frames = {}
if frame is not None:
frames["image"] = frame
if seam_frame is not None:
frames["seam_image"] = seam_frame
_PREVIEW.submit(payload, frames)
def _offload_text_encoder(clip, model):
te_dev = getattr(clip.patcher, "load_device", None)
dit_dev = getattr(model, "load_device", None)
if te_dev is not None and dit_dev is not None and str(te_dev) != str(dit_dev):
return
try:
clip.patcher.model.to(mm.text_encoder_offload_device())
except Exception as e:
print(f"[{TAG}] TE offload skipped: {e}", flush=True)
return
try:
dev = mm.get_torch_device()
mm.free_memory(mm.get_total_memory(dev) * 0.9, dev)
mm.soft_empty_cache()
free = mm.get_free_memory(dev) / (1024 ** 3)
print(f"[{TAG}] TE evicted; {free:.1f} GB free for the DiT", flush=True)
except Exception as e:
print(f"[{TAG}] VRAM purge skipped: {e}", flush=True)
def _decode_av(video_vae, audio_vae, latent):
imgs = VAEDecode().decode(video_vae, latent)[0]
audio = vae_decode_audio(audio_vae, latent)
return imgs, audio
def _resample_wav(wav, src_sr, dst_sr):
src_sr, dst_sr = int(src_sr), int(dst_sr)
if src_sr == dst_sr:
return wav
import torchaudio
return torchaudio.functional.resample(wav, src_sr, dst_sr)
def _prepare_master_audio(path):
"""Load the take once: stereo, native rate kept, plus a 32 kHz copy."""
got = _media.load_audio(path)
if got is None:
raise ValueError(
f"{TAG}: master_audio_file {path!r} did not load. The file has to "
"resolve under h3_refs the same way voice_file does.")
wav = _alock.force_stereo(got["waveform"].contiguous().cpu())
sr = int(got["sample_rate"])
wav32 = _resample_wav(wav, sr, _alock.VAE_SR)
digest = _store.audio_digest({"waveform": wav, "sample_rate": sr})
print(f"[{TAG}] master_audio_file: loaded {path!r} "
f"({wav.shape[-1] / sr:.2f}s at {sr} Hz, stereo)", flush=True)
return {"path": path, "wav": wav, "sr": sr, "wav32": wav32,
"digest": digest}
def _encode_locked_slice(audio_vae, wav32, t0, t1, audio_latent_len):
"""VAE-encode one hop's window of the 32 kHz take. -> audio latent tensor."""
start, end = _alock.sample_range(t0, t1, _alock.VAE_SR)
picture_n = max(1, end - start)
grid_n = _alock.grid_samples(audio_latent_len, _alock.VAE_SR)
enc_n = max(picture_n, grid_n)
enc = _alock.fit_samples(wav32, start + enc_n)[..., start:start + enc_n]
# song_lock: encode [B, T, C]. wav32 is [C, T].
batch = enc.unsqueeze(0).movedim(1, -1)
try:
z = audio_vae.encode(batch)
except Exception as e:
raise RuntimeError(
f"{TAG}: audio VAE encode for master_audio_file failed ({e!r}). "
"The lock follows PromptMasterLD song_lock.py "
"(encode at 32 kHz on the 40 Hz grid). If this ComfyUI's "
"audio VAE uses a different signature, that is a version "
"mismatch -- report this error."
) from e
got = int(z.shape[-1])
if got < int(audio_latent_len):
extra = int(math.ceil(
(int(audio_latent_len) - got + 1) * _alock.VAE_SR / _alock.AUDIO_HZ))
enc2 = _alock.fit_samples(wav32, start + enc_n + extra)[
..., start:start + enc_n + extra]
z = audio_vae.encode(enc2.unsqueeze(0).movedim(1, -1))
got = int(z.shape[-1])
if got < int(audio_latent_len):
raise RuntimeError(
f"{TAG}: audio VAE produced {got} steps, hop needs "
f"{int(audio_latent_len)}. The take window was "
f"{t0:.3f}s-{t1:.3f}s.")
return z[..., :int(audio_latent_len)]
def _splice_locked_audio(latent, z_audio):
"""Replace the hop's audio latent and freeze it. Video stays live."""
import comfy.nested_tensor as nt
parts = _latents.from_dict(latent)
if parts is None or len(parts) < 2:
raise RuntimeError(
f"{TAG}: master_audio_file splice needs a joint AV latent "
f"(video+audio); got {type((latent or {}).get('samples')).__name__}.")
video, audio = parts[0], parts[1]
z = z_audio.to(device=audio.device, dtype=audio.dtype)
if int(z.shape[-1]) != int(audio.shape[-1]):
raise RuntimeError(
f"{TAG}: locked audio latent length {int(z.shape[-1])} != "
f"hop audio length {int(audio.shape[-1])}.")
# Match batch/leading dims; take the first encoded copy if we produced extra.
while z.dim() < audio.dim():
z = z.unsqueeze(0)
while z.dim() > audio.dim():
z = z[0]
if tuple(z.shape[:-1]) != tuple(audio.shape[:-1]):
try:
z = z.expand(audio.shape)
except RuntimeError as e:
raise RuntimeError(
f"{TAG}: locked audio shape {tuple(z.shape)} will not fit "
f"hop audio {tuple(audio.shape)} ({e}).") from e
parts[1] = z
out = dict(latent)
out["samples"] = _latents.rebuild(latent["samples"], parts)
# song_lock polarity: ones on video (denoise), zeros on audio (freeze).
v_shape = (1, 1) + tuple(int(d) for d in video.shape[2:])
a_shape = (1, 1) + tuple(int(d) for d in audio.shape[2:])
vmask = torch.ones(v_shape, device=video.device, dtype=torch.float32)
amask = torch.zeros(a_shape, device=audio.device, dtype=torch.float32)
_alock.assert_mask_polarity(vmask, amask)
out["noise_mask"] = nt.NestedTensor((vmask, amask))
return out
def _slice_take_audio(prepared, t0, t1, sr):
"""The take window, resampled to `sr`, as an AUDIO dict. For the pin."""
start, end = _alock.sample_range(t0, t1, prepared["sr"])
chunk = _alock.fit_samples(prepared["wav"], end)[..., start:end]
chunk = _resample_wav(chunk, prepared["sr"], int(sr))
return {"waveform": chunk.unsqueeze(0), "sample_rate": int(sr)}
class HandTieClips:
"""Refs + shot plan + N hops, assembled into one clip.
Each hop after the first pins the previous hop's sampler latent through
Motion-Context, falling back to an AddGuide pixel pin when that is not
available. See the module docstring.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"vae": ("VAE",),
"audio_vae": ("VAE",),
"prompt": ("STRING", {
"multiline": True,
"dynamicPrompts": False,
"default": (
"Live-action, natural indoor light. The person looks exactly "
"as in the reference photographs.\n\n"
"They sit at a table, look up, and speak one short line. "
"Then they settle, watching the room."
),
"tooltip": (
"Hop 1 prompt. Separate hops with --- on its own line, "
"or JSON {\"prompts\": [...]}. hop_script=verbatim: one block "
"+ chains>1 wraps later hops. hop_script=next: later --- blocks "
"are only 'what happens next'."
),
}),
"chains": (["1", "2", "3", "4", "5", "6", "7", "8"], {
"default": "3",
"tooltip": "How many generates to run and join. 3 at 10 s is about 28 s of master after the overlap trim.",
}),
"resolution": (list(RESOLUTIONS), {
"default": DEFAULT_RESOLUTION,
"tooltip": "Output area. 0.98 MP is the top rung because H3 caps at 768x1344 (1.03 MP); 16:9 there is 1312x736. Every size is snapped to H3's 32 px grid and kept under the cap.",
}),
"aspect": (list(ASPECTS), {
"default": DEFAULT_ASPECT,
"tooltip": "Frame shape. Combined with resolution to set width and height. Widest first, then square, then the portraits.",
}),
"duration": (["5 s", "7 s", "8 s", "10 s", "15 s"], {
"default": "10 s",
"tooltip": "Length of each hop at 24 fps (H3 17k+5 grid: 124 / 192 / 243 / 362 frames). 5 s (124f) drops the airlock on a continuous join; use 8 s or 15 s to validate a seam.",
}),
"overlap": (["0.9 s", "0.2 s", "1.6 s"], {
"default": "0.9 s",
"tooltip": "Pinned clip from the previous hop at frame 0. 0.9 s (22 frames) is the native continuation length.",
}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 0xffffffffffffffff,
"control_after_generate": True,
}),
"seed_per_shot": ("BOOLEAN", {
"default": True,
"label_on": "vary per hop",
"label_off": "same seed every hop",
}),
"steps": ("INT", {"default": 14, "min": 1, "max": 50}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta"}),
"shift_video": ("FLOAT", {"default": 12.0, "min": 0.01, "max": 100.0, "step": 0.01}),
"shift_audio": ("FLOAT", {"default": 3.0, "min": 0.01, "max": 100.0, "step": 0.01}),
"ref_image_size": (["match", "max"], {
"tooltip": "match = faster. max = 2048 short-edge identity, slower every step.",
}),
},
"optional": {
# The nine ref_image_N sockets, plus reference_video, voice
# and start_image, used to live at the TOP of this block --
# twelve of a sixteen-socket column that occupied a third of
# the node before the editor started. They are files now, and
# their replacements are appended at the BOTTOM of `optional`
# instead. See the note beside them: widget ORDER is part of
# the saved-workflow format.
"hop_script": (["verbatim", "next"], {
"default": "verbatim",
"tooltip": (
"verbatim: your text is the hop prompt. next: hop 1 is the first "
"block; every later block is only the new beat. One block + next: "
"hops 2+ advance without replaying the opening."
),
}),
"pin_to_qwen": (["off", "last frame", "pin clip", "both"], {
"default": "last frame",
"tooltip": (
"AddGuide is invisible to the text encoder. last frame = "
"<Picture 1> of the previous hop's last frame (identity stills "
"shift to Picture 2+). pin clip = overlap as extra <Video>. "
"Voice stays <Audio 1>."
),
}),
"continuity_state": ("STRING", {
"multiline": True,
"default": "",
"forceInput": True,
"tooltip": (
"Optional JSON continuity state from HTCContinuityState (or a String "
"Primitive node for hand-typed JSON). hop_script=next only: locked + "
"context text rides every hop 2+, mutable beats are indexed per hop. "
"Unwired = no effect."
),
}),
"shot_plan": ("STRING", {
"multiline": True,
"default": "",
"tooltip": (
"Shot plan JSON: {\"shots\":[{\"beat\":\"...\","
"\"directives\":{\"join\":\"continuous\"}}, ...]}. "
"The shot count is the hop count, so `chains` is ignored. "
"Directives compile to vetted continuity prose; `prose` per shot "
"is appended verbatim. Blank = use the `prompt` widget instead."
),
}),
"ref_plan": ("STRING", {
"multiline": True,
"default": "",
"tooltip": (
"Reference register JSON: {'refs':[{'tag':'hero_face',"
"'file':'face.png','subject':1,'retention':'fully_preserved'}]}. "
"'file' is a picture in the reference folder, chosen in the panel. "
"Beats refer to refs by @tag, resolved to the correct "
"<Picture N> per hop, so removing or scheduling off a ref "
"never renumbers the others. Refs sharing a 'subject' are "
"the same person; different numbers stay different people. "
"Blank = positional behaviour: refs are read in slot order."
),
}),
"cache_hops": (["off", "on"], {
"default": "off",
"tooltip": (
"Store each hop losslessly on disk, keyed by a chained "
"content hash. Unchanged hops load instead of re-rendering, "
"so editing only the last shot re-renders only that shot, "
"and an interrupted chain resumes. Editing an early shot "
"correctly invalidates every hop after it."
),
}),
"cache_budget_gb": ("FLOAT", {
"default": 20.0, "min": 1.0, "max": 500.0, "step": 1.0,
"tooltip": "Least-recently-used hops are evicted above this size.",
}),
"audio_pin_frames": ("INT", {
"default": 24, "min": 0, "max": 240, "step": 24,
"tooltip": (
"Audio context handed to the Motion-Context pin, in frames. "
"24 is one second and lands on the model's 40 Hz audio grid; "
"multiples of 24 keep whole seconds. Longer audio context "
"costs conditioning rows but NO delivered frames, so it is "
"the cheap lever on speech that breaks across a join -- try "
"96 (4 s) for continuous dialogue. 0 follows the picture "
"overlap. Video pin length is not adjustable here: it "
"follows the overlap widget."
),
}),
"pin_renorm": (["off", "sigma", "band"], {
"default": "off",
"tooltip": (
"Rescale each pinned latent back toward the first pinned "
"hop's, to fight the texture ratchet -- measured at +4.2% "
"mid-band per join, flat inside each hop. Both modes are "
"scalar rescales, so neither moves structure or can blur "
"detail. "
"band: match the HIGH-BAND FRACTION, the statistic the "
"ratchet actually moves. "
"sigma: match total spread -- the original lever, kept "
"for old workflows, and measurably the wrong statistic: "
"total sigma FALLS across a chain whose picture is "
"baking, so it corrects the wrong way. Saved as `on` "
"before 0.5. "
"off leaves every pin untouched. The log prints "
"`pin drift` every hop either way, so you can read the "
"ratchet without changing anything."
),
}),
"pin_noise": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 0.10, "step": 0.005,
"tooltip": (
"Mix seeded noise into the pinned latent before it "
"conditions the next hop -- the other half of the texture "
"ratchet fix. Small values only: measured gains fall off "
"and reverse above 0.10, which is why the range stops "
"there. 0.0 leaves the pin untouched; 0.05 is the "
"suggested starting point."
),
}),
"tone_compensate": (_tone.MODES, {
"default": "off",
"tooltip": (
"Undo the denoiser's tone bias on each hop, measured on the "
"overlap that hop regenerated. The estimate needs both copies "
"of the overlap, which only exist inside this node -- a "
"downstream node cannot do this. Enabling any mode also "
"clamps the master to 0..1. "
"MEASURED on a 3-hop chain, mean seam step against off "
"(2.36/255): anchor 0.68, gain_bias 0.76, lut 0.77, "
"frame_shift 1.35. anchor is the one to reach for. Every "
"mode OVERSHOOTS -- an uncorrected seam brightens, a "
"corrected one darkens -- and none of them fixes the FIRST "
"join, which all four overshoot by a similar margin."
),
}),
# ---------------------------------------------------------
# APPEND-ONLY ZONE. `widgets_values` in a saved workflow is a
# POSITIONAL array -- ComfyUI restores value[i] into widget[i]
# and never checks the name. A widget inserted anywhere but the
# end renumbers everything after it, and every previously saved
# workflow silently loads its values into the wrong widgets.
#
# These three were first added at the top of `optional`, which
# shifted hop_script..tone_compensate by +3. `ref_plan` then
# received audio_pin_frames' integer and the editor threw
# `(text || "").trim is not a function` on load -- the only
# visible symptom of a much wider silent corruption.
#
# Add new widgets HERE, at the bottom. Old workflows are then
# short rather than misaligned, and the new widget takes its
# default.
# ---------------------------------------------------------
"start_image_file": ("STRING", {
"default": "",
"tooltip": "First-frame pin for hop 1 only. Set in the panel.",
}),
"reference_video_file": ("STRING", {
"default": "",
"tooltip": "Motion/look plate. Not the previous hop. Set in the panel.",
}),
"voice_file": ("STRING", {
"default": "",
"tooltip": (
"Voice or timbre reference for hop 1, cited as <Audio 1>. "
"Later hops use the audio pin instead -- an uncited "
"timbre clip on a quiet hop fills leftover frames with "
"that recording. Set in the panel."
),
}),
# Appended here on 2026-08-29, per the note above: LAST, so
# every saved workflow keeps its widget alignment and simply
# takes the default.
"establish": ("STRING", {
# Single line on purpose. The editor hides only the widgets
# it explicitly owns, so this one renders as a native widget
# on the node body -- and one sentence in a one-line box is
# discoverable where a multiline textarea would be a slab.
"default": _d.ESTABLISH,
"tooltip": (
"Opening line prepended to hop 1 only, before the beat. "
"The default asserts live action; clear it, or replace it "
"with your own medium, for anything else. Dropped "
"automatically when shot 1 already names a medium."
),
}),
# Appended 2026-08-30, still obeying the append-only rule above.
"render_through": ("INT", {
"default": 0, "min": 0, "max": 64,
"tooltip": (
"Stop after this many hops. 0 renders the whole plan. "
"With cache_hops=on the hops you already rendered are "
"kept, so 3 then 5 then 8 builds a chain up in stages "
"and only ever renders the new hops. The plan is not "
"changed -- shot 4 still knows it is shot 4."
),
}),
"quality": (["final", "draft"], {
"default": "final",
"tooltip": (
"draft forces 0.3 MP and 6 steps for a fast structural "
"read of the whole chain -- does the story hold, do the "
"joins land. Resolution and steps are both in the cache "
"key, so drafts and finals never overwrite each other; "
"they simply cost two entries."
),
}),
"dry_run": (["off", "on"], {
"default": "off",
"tooltip": (
"Compile every hop's prompt and stop -- no sampling, no "
"model, seconds not minutes. Read them on `info`, or as "
"a page on `contact_sheet`. This is the only way to see "
"what the text encoder will actually receive before "
"paying for it."
),
}),
"contact_sheet": (["off", "on"], {
"default": "off",
"tooltip": (
"Build the `contact_sheet` output: one row per hop with "
"its first and last delivered frame, its beat, its "
"directives and what happened to it. Wire it to a Save "
"Image. Always built during a dry run."
),
}),
"tone_anchor": ("FLOAT", {
"default": _tone.ANCHOR_STRENGTH, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": (
"Strength of tone_compensate=anchor's pull back toward "
"hop 1's look -- its L* level, its a*/b* colour and its "
"L* spread. Ignored by every other mode. 0 disables the "
"pull and leaves plain frame_shift; 0.35 closes about a "
"third of the gap per hop, which arrests a long slide "
"without visibly pumping. Raise it to 0.6-0.8 for long "
"chains that grey out: a measured 9-hop study lost a "
"fifth of its chroma by the end, and 0.35 only slows "
"that. Costs about 15 s per 15 s hop at 1344x768: the "
"measurement is in Lab, which is a colour-space round "
"trip over every frame. A shot can opt out with "
"\"tone\": \"free\" or move the anchor to itself with "
"\"tone\": \"rebase\"."
),
}),
# APPENDED, never inserted -- the same rule the ref sockets at
# the top of this block were deleted for. `widgets_values` is a
# positional list, so a widget added anywhere but the end shifts
# every widget after it, and a workflow saved before the change
# reads its own settings out of the wrong slots. Silently: the
# numbers all still parse, they are just the wrong numbers.
"soundtrack": ("AUDIO", {
"tooltip": (
"Optional music bed under the whole chain, mixed in once "
"after the last hop is joined. A mix, not a replacement: "
"H3's own dialogue and effects stay. Wire a Load Audio, "
"or anything with an AUDIO output. Unwired, the audio "
"output is untouched."
),
}),
"music_gain_db": ("FLOAT", {
"default": -14.0, "min": -60.0, "max": 6.0, "step": 0.5,
"tooltip": (
"Level of the bed against the generated audio. -14 sits "
"a track under speech without fighting it; -6 is a "
"music-led cut. Push it far enough and the peak guard "
"trims the whole mix rather than let it clip -- which it "
"says in `info` rather than doing quietly."
),
}),
"music_duck": ("FLOAT", {
"default": 0.6, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": (
"Pull the bed down while anyone is talking and let it "
"back up in the gaps. 0 is off: a flat bed at "
"music_gain_db and nothing else. 0.6 drops it about 8 dB "
"under speech, which is what keeps dialogue intelligible "
"under a loud track. Fast attack, slow release, no "
"model -- same result every run."
),
}),
"music_fit": (["loop", "once"], {
"default": "loop",
"tooltip": (
"loop: repeat the track to cover the chain, crossfading "
"each wrap so it cannot click. once: play it through and "
"leave silence after. A track longer than the chain is "
"trimmed either way."
),
}),
"music_fade_s": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.25,
"tooltip": (
"Seconds of fade on the bed at the start and end of the "
"finished chain, so it neither begins on a cut nor stops "
"on a dropout. Also sets the loop crossfade length."
),
}),
# The picker's half of the soundtrack, and the one most people
# use. Same shape as voice_file: a basename under h3_refs, set
# in the panel. LAST, per the note at the top of this block.
"soundtrack_file": ("STRING", {
"default": "",
"tooltip": (
"Music bed as a filename, set in the panel next to the "
"voice reference. The `soundtrack` socket wins when both "
"are set."
),
}),
# Trim windows, appended 2026-09-01 -- LAST, per the note at
# the top of this block. Six floats rather than one JSON blob
# because these three slots are fixed and named, the same
# reason `voice_file` is its own widget. The rail's references
# are a LIST, which is why their per-item settings live in
# `ref_plan` instead.
#
# An end of 0.0 is the sentinel for "to the end of the file",
# so the default pair (0, 0) is untrimmed and costs nothing.
# media.clip_window is the single definition of what they mean.
"voice_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": (
"Trim window into the voice reference, in seconds. Leave both at 0 for "
"the whole file; an end of 0 always means "
"'to the end', so a longer replacement file "
"still plays out. "
"Worth setting: H3 encodes the WHOLE voice file into the "
"conditioning with no cap, and every latent frame of it "
"is attended over on every step of every hop. A "
"three-minute take is a large invisible tax."
),
}),
"voice_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "End of the voice window. 0 = to the end of the file.",
}),
"reference_video_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": (
"Trim window into the reference clip, in seconds. Leave both at 0 for "
"the whole file; an end of 0 always means "
"'to the end', so a longer replacement file "
"still plays out. "
"H3 already truncates the clip to the hop length, but "
"only from frame 0 -- so without this there is no way to "
"point at the motion you actually want."
),
}),
"reference_video_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "End of the reference clip window. 0 = to the end.",
}),
"music_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": (
"Trim window into the soundtrack, in seconds. Leave both at 0 for "
"the whole file; an end of 0 always means "
"'to the end', so a longer replacement file "
"still plays out. "
"The window is cut from the TRACK first; music_fit then "
"loops or trims that to the chain. Without it a mastered "
"track always starts the chain on its intro."
),
}),
"music_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "End of the soundtrack window. 0 = to the end.",
}),
# Appended 2026-09-03 -- LAST, per the note at the top of this
# block. `render_through` has been here since 0.4 and stops the
# chain; this is the other end of the same range.
"render_from": ("INT", {
"default": 0, "min": 0, "max": 64,
"tooltip": (
"Start at this hop instead of hop 1. 0 starts at the "
"beginning. Everything before it is replayed from the "
"hop cache rather than rendered, so re-running one shot "
"in the middle of a long chain costs that shot. "
"Needs cache_hops=on, and every earlier hop must "
"already be in the cache -- it names the first one that "
"is not rather than guessing at the join. Pair it with "
"render_through to render a range."
),
}),
"reference_video_desc": ("STRING", {
"default": "",
"tooltip": (
"What the reference clip is for, in your words -- "
"\"a slow dolly along a counter\", \"the way she turns "
"and looks back\". The clip goes in as <Video 1> either "
"way; this is the only thing that tells the encoder "
"why it is there, and a reference the prompt never "
"explains tends to get rendered as the shot. Leave it "
"empty and the prompt is exactly what it was."
),
}),
# Appended 2026-09-03 -- LAST, per the note at the top of this
# block.
"reference_video_size": (list(_media.VIDEO_SIZES), {
"default": _media.DEFAULT_VIDEO_SIZE,
"tooltip": (
"How large to decode the reference clip, as an area "
"budget. MAX asks core what it would resize the clip "
"to anyway, so the model sees the same pixels and the "
"memory is not spent -- a 10 s 4K plate costs about "
"36 GB of system RAM decoded at source and about "
"4.5 GB at MAX. The megapixel values go below that, "
"trading reference detail for memory. A clip already "
"smaller than the value you pick is left alone; "
"nothing here ever scales up. Megapixels are decimal "
"here -- 0.5 MP is 500,000 pixels, whatever the clip's "
"aspect ratio, which is the point of budgeting by area "
"rather than by edge. "
"It is also an INFLUENCE dial, not only a memory one. "
"Area sets how many tokens the clip costs, and that is "
"how loudly it speaks: core aligns reference frame N "
"with output frame N, so wherever the clip shows a "
"clear face it competes with your identity stills for "
"that same face. At MAX it wins. An identity swap that "
"only takes hold part-way through the hop -- the "
"clip's person at the start, yours once the "
"clip's face is obscured -- is this, and 0.3 MP "
"fixed it on a measured case. Lower it when "
"identity matters more than the clip's detail."
),
}),
# APPENDED, never inserted. `widgets_values` is a bare ordered
# array matched to this schema by index, so a widget added
# anywhere but the end silently reassigns every later value in
# every saved workflow. Adding options to an existing combo is
# safe; adding a widget is not.
"pin_mech": (["auto", "motion_context", "addguide"], {
"default": "auto",
"tooltip": (
"Which mechanism pins hops 2+ to the previous hop. "
"auto = Motion-Context when the pack is installed, the "
"overlap has a matching context_length and the previous "
"hop left a sampler latent; AddGuide pixels otherwise. "
"Forcing one does not fall back -- it fails with the "
"reason, because a lever that silently becomes the "
"other setting cannot be compared against it. "
"motion_context: latent join, no decode/re-encode. "
"addguide: re-encodes decoded pixels, which is itself a "
"VAE round trip and may scrub differently. The "
"mechanism is in the per-hop cache key, so switching "
"re-renders hops 2+ and leaves hop 1 on disk."
),
}),
# APPENDED, never inserted -- see the note above `pin_mech`.
"tone_anchor_ref": (["hop1", "still"], {
"default": "hop1",
"tooltip": (
"What tone_compensate=anchor pulls TOWARD. Ignored by "
"every other mode. hop1 is the original behaviour: the "
"chain holds whatever tone hop 1 rendered. still uses "
"start_image instead, and pulls hop 1 as well -- which "
"matters because hop 1 already misses the photograph "
"before any relay has happened. A measured 9-hop study "
"read the still at chroma 33.6 and hop 1 at 30, so a "
"chain anchored on hop 1 is holding a target that is "
"already short. Needs start_image_file set. Under the "
"Motion-Context join the correction still only reaches "
"the delivered frames, not the pin -- set "
"pin_mech=addguide for it to feed back."
),
}),
# APPENDED after pin_mech and tone_anchor_ref. Those two already
# shipped on this trunk; the 3x3 extra slots go last so existing
# v2 workflows keep their last two values as pin_mech /
# tone_anchor_ref. H3 natively takes 9 reference images, 3
# reference videos and 3 standalone reference audios; the pack
# matched the 9 and passed exactly one of each of the others.
"reference_video_2_file": ("STRING", {
"default": "",
"tooltip": (
"Reference clip 2 of 3. H3 takes three; this pack "
"passed one until now. Cited as <Video 2> when every "
"earlier slot is filled -- the numbering is dense, so "
"clearing slot 2 renumbers slot 3. Decoded at the same "
"reference video size as slot 1. Set in the panel."
),
}),
"reference_video_2_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim in, seconds, for reference clip 2.",
}),
"reference_video_2_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim out, seconds, for reference clip 2. 0 = to the end.",
}),
"reference_video_3_file": ("STRING", {
"default": "",
"tooltip": (
"Reference clip 3 of 3. H3 takes three; this pack "
"passed one until now. Cited as <Video 3> when every "
"earlier slot is filled -- the numbering is dense, so "
"clearing slot 2 renumbers slot 3. Decoded at the same "
"reference video size as slot 1. Set in the panel."
),
}),
"reference_video_3_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim in, seconds, for reference clip 3.",
}),
"reference_video_3_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim out, seconds, for reference clip 3. 0 = to the end.",
}),
"voice_2_file": ("STRING", {
"default": "",
"tooltip": (
"Voice reference 2 of 3, cited as <Audio 2>. H3 takes "
"three standalone reference audios; this pack passed one "
"until now. Dense numbering, so clearing slot 2 renumbers "
"slot 3 -- and a beat that names an ordinal would then "
"cite the wrong voice. Every reference audio is attended "
"on every step of every hop, so trim them. Set in the panel."
),
}),
"voice_2_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim in, seconds, for voice 2.",
}),
"voice_2_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim out, seconds, for voice 2. 0 = to the end.",
}),
"voice_3_file": ("STRING", {
"default": "",
"tooltip": (
"Voice reference 3 of 3, cited as <Audio 3>. H3 takes "
"three standalone reference audios; this pack passed one "
"until now. Dense numbering, so clearing slot 2 renumbers "
"slot 3 -- and a beat that names an ordinal would then "
"cite the wrong voice. Every reference audio is attended "
"on every step of every hop, so trim them. Set in the panel."
),
}),
"voice_3_start_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim in, seconds, for voice 3.",
}),
"voice_3_end_s": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
"tooltip": "Trim out, seconds, for voice 3. 0 = to the end.",
}),
# APPENDED, never inserted. Empty string leaves every existing
# path byte-identical: the lock is not called, the cache key
# does not move, the delivered audio is still the generated
# chain. The file is ElevenLabs (or any) TTS / a real take;
# every hop lip-syncs to one continuous window of it.
"master_audio_file": ("STRING", {
"default": "",
"tooltip": (
"One continuous voice take every hop lip-syncs to. "
"Basename under h3_refs. Empty = off, generated voice "
"as before. When set: the take is sliced on the same "
"clock as the picture (hop 1 starts at 0.00s), encoded "
"on the 40 Hz audio-latent grid, and frozen with a "
"noise_mask so only the picture is denoised. Delivered "
"audio is a passthrough of this file, no VAE round "
"trip. The beat still needs the words in "
"<d>[English] ...</d> -- unmatched text can pull the "
"mouth off the take. Changing the file invalidates "
"the hop cache."
),
}),
"last_frame_guide": (["off", "before_restart", "still"], {
"default": "off",
"tooltip": (
"Pin start_image at a hop's last PIXEL frame "
"(AddGuide frame_idx=-1), so the hop ENDS on the "
"photograph. off = shipped behaviour, frame 0 only. "
"before_restart = only on a hop whose NEXT shot is "
"anchor=restart; that restart opens on the same "
"photograph, so both sides of the cut meet on one "
"image and it reads as a match cut rather than a "
"jump. That is the recommended setting. "
"still = every hop: the same benefit at the restart, "
"but it overrides an authored framing directive at "
"EVERY hop ending. A shot set framing=close plays "
"close for six seconds, snaps to the still's wider "
"framing in about 0.6 s, and the next hop pushes "
"back in -- watched, that reads as the camera "
"cutting in and out. Safe only when no shot authors "
"a framing. Needs start_image_file. Does NOT become "
"the next hop's frame 0; that is keyframe chaining, "
"which this is not."
),
}),
# APPENDED 2026-09-09, LAST for the same reason as everything
# else down here: widgets_values is positional and a saved
# workflow reads it by index.
"voice_every_hop": (["off", "speaking", "on"], {
"default": "off",
"tooltip": (
"Whether voice 1-3 stay cited as <Audio 1..3> after "
"hop 1. off = shipped behaviour, hop 1 only: later "
"hops inherit the timbre through the audio pin, "
"which drifts over a long chain. speaking = ride "
"every hop whose beat has a spoken line, skip the "
"quiet ones -- RECOMMENDED, and the setting that "
"keeps one voice across a whole chain. on = ride "
"every hop regardless. What off is protecting "
"against: an uncited timbre clip on a hop with no "
"line fills the leftover frames with that "
"recording, which once put a 1.35 s male take into "
"the last second of a hop whose written line "
"followed a woman's face. That needs a QUIET hop, "
"so speaking cannot reach it. Moves the cache key "
"of every hop it changes."
),
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
# contact_sheet is APPENDED, never inserted: a saved workflow's links name
# an output by index, so putting it anywhere but last would silently rewire
# every graph that already uses this node.
RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "IMAGE")
RETURN_NAMES = ("images", "audio", "info", "contact_sheet")
FUNCTION = "run"
CATEGORY = "Hand Tie Clips"
DESCRIPTION = (
"MiniMax H3 Ref2VA chain. Hop 1 is a full generate; every later hop is a "
"continuation pinned to the previous hop's sampler latent via "
"Motion-Context, falling back to an AddGuide pixel pin when that is "
"unavailable. Author with shot_plan + ref_plan; hop_script=next treats "
"later blocks as what-happens-next; pin_to_qwen shows the incoming frame "
"to the text encoder; continuity_state (from HTCContinuityState) carries "
"locked/context setting text forward."
)
@classmethod
def IS_CHANGED(cls, ref_plan="", start_image_file="",
reference_video_file="", voice_file="",
soundtrack_file="",
reference_video_2_file="", reference_video_3_file="",
voice_2_file="", voice_3_file="",
master_audio_file="", **_):
"""Re-run when a reference file changes underneath its name.
Every picture now arrives as a basename, and a basename is a stable
input: overwrite `face.png` with a different face and ComfyUI would
happily serve the previous render. Hashing path+mtime is the fix.
Deliberately NOT `float("nan")` -- that is the blunt version of this and
would force a full re-render of an expensive node on every queue.
"""
names = [start_image_file, reference_video_file, voice_file,
soundtrack_file,
reference_video_2_file, reference_video_3_file,
voice_2_file, voice_3_file,
master_audio_file]
try:
for r in (_refs.parse_ref_plan(ref_plan).get("refs") or []):
if r.get("file"):
names.append(r["file"])
except Exception:
# A malformed plan is run()'s error to report, with a message that
# names the field. Raising here would surface as a cache failure.
pass
return _media.stamp(names)
def run(self, model, clip, vae, audio_vae, prompt,
chains, resolution,
aspect, duration, overlap, seed, seed_per_shot, steps,
sampler_name, scheduler, shift_video, shift_audio, ref_image_size,
start_image_file="", reference_video_file="", voice_file="",
hop_script="verbatim", pin_to_qwen="last frame", continuity_state="",
shot_plan="", ref_plan="", cache_hops="off", cache_budget_gb=20.0,
audio_pin_frames=24, pin_renorm="off", pin_noise=0.0,
tone_compensate="off", establish=None,
render_through=0, quality="final", dry_run="off",
contact_sheet="off", tone_anchor=_tone.ANCHOR_STRENGTH,
soundtrack=None, music_gain_db=-14.0, music_duck=0.6,
music_fit="loop", music_fade_s=1.0, soundtrack_file="",
voice_start_s=0.0, voice_end_s=0.0,
reference_video_start_s=0.0, reference_video_end_s=0.0,
music_start_s=0.0, music_end_s=0.0, render_from=0,
reference_video_desc="",
reference_video_size=None,
pin_mech="auto", tone_anchor_ref="hop1",
reference_video_2_file="", reference_video_2_start_s=0.0,
reference_video_2_end_s=0.0,
reference_video_3_file="", reference_video_3_start_s=0.0,
reference_video_3_end_s=0.0,
voice_2_file="", voice_2_start_s=0.0, voice_2_end_s=0.0,
voice_3_file="", voice_3_start_s=0.0, voice_3_end_s=0.0,
master_audio_file="",
last_frame_guide="off",
voice_every_hop="off",
unique_id=None):
# First thing, before a single model is touched: hand the writer's VRAM
# back. The plan writer stays resident between plans now, which is the
# right trade everywhere except here -- a 27B and an H3 render do not
# share a card, and "write a plan, then queue" would OOM in a way that
# reads as this node's fault.
#
# This BLOCKS, deliberately. `run()` is the execution worker thread, not
# the aiohttp event loop that `llm.py`'s no-blocking rule protects, and
# the render is precisely what the VRAM is being freed for. Costs
# nothing when the writer was never configured or nothing is resident,
# which is almost every render; see `llm.free_for_render`.
try:
from . import llm as _llm
_llm.free_for_render()
except Exception as _le: # noqa: BLE001
print(f"[{TAG}] writer unload skipped ({_le!r})", flush=True)
dry = str(dry_run) == "on"
draft = str(quality) == "draft"
want_sheet = str(contact_sheet) == "on" or dry
if draft:
# Both of these are already in the cache key, so a draft cannot
# collide with the final it is standing in for.
resolution, steps = DRAFT_RESOLUTION, min(int(steps), DRAFT_STEPS)
print(f"[{TAG}] draft: {DRAFT_RESOLUTION}, {steps} steps", flush=True)
width, height = _canvas(resolution, aspect)
# Nothing downstream checks this. Core takes the generation canvas on
# trust and floor-divides it by 16 for the latent, so an off-grid size
# does not raise -- it renders a smaller frame than `master_imgs` is
# allocated for and the mismatch surfaces later as a shape error with no
# obvious cause. Fail here, where the size and its two inputs are known.
if width % CANVAS_MULTIPLE or height % CANVAS_MULTIPLE:
raise ValueError(
f"canvas {width}x{height} from resolution={resolution!r} "
f"aspect={aspect!r} is not a multiple of {CANVAS_MULTIPLE}; "
"H3 cannot render it.")
print(f"[{TAG}] canvas {width}x{height} "
f"({width * height / 1e6:.2f} MP, {width / height:.3f}:1) "
f"from {resolution} {aspect}", flush=True)
length = align_frame_count(_duration_frames(duration))
overlap_n = _overlap_frames(overlap)
# A shot plan is authoritative when present: its shot count is the hop
# count, and every shot after the first is a beat, so `next` semantics
# are the only correct reading of it.
shots = _plan.parse_plan(shot_plan)
if shots:
if str(hop_script) != "next":
print(f"[{TAG}] shot_plan present -> hop_script=next", flush=True)
hop_script = "next"
if int(chains) != len(shots):
print(f"[{TAG}] shot_plan has {len(shots)} shot(s); "
f"chains={chains} ignored", flush=True)
# The place-handoff check needs to know which tags are places,
# which only the ref plan says. Parsed defensively here: the
# authoritative parse (and its error) is still the one below, so a
# malformed ref_plan fails in the same place it always did.
try:
_rp_for_check = _refs.parse_ref_plan(ref_plan)
except Exception:
_rp_for_check = None
blocks = _plan.compile_blocks(shots, establish, _rp_for_check)
unique = len(shots)
print(f"[{TAG}] shot plan:\n" + _plan.describe(shots), flush=True)
for i, sh in enumerate(shots):
if i > 0 and _d.is_full_h3_prompt((sh or {}).get("beat")):
print(
f"[{TAG}] hop {i + 1}: full H3 prompt flattened to a "
"continuation beat (a complete Ref2VA block on hop 2+ "
"starts a new scene)",
flush=True,
)
else:
shots = [None] * int(chains)
blocks, unique = _expand_shots(
_parse_shots(prompt), int(chains), hop_script=str(hop_script))
n = len(blocks)
# render_through truncates the RENDER, never the plan: the shots that
# survive keep their own indices, seeds and cache keys, so extending the
# stop point later re-uses every hop already on disk instead of
# renumbering them into fresh misses.
stop_at = int(render_through or 0)
# render_from is the other end of the same range, and unlike
# render_through it truncates nothing at all: the hops before it still
# run through the loop, they just have to come out of the cache instead
# of the sampler. Validated against the store further down, once there
# is a store to validate against.
start_at = int(render_from or 0)
# An impossible range is refused BEFORE anything is announced. Both
# halves of that matter. Against the original `render_through`, because
# `n` is truncated to it below and testing against the truncated value
# reports an inverted range as "past the end of the plan" and then
# renders everything. And before the print below, because that print
# says "rendering hops 1-2" -- which was the last thing in the log
# ahead of a refusal to render anything at all.
if start_at > 1 and 0 < stop_at < start_at:
raise ValueError(
f"{TAG}: render_from={start_at} is past "
f"render_through={stop_at}, so the range is empty. "
"render_through is the LAST hop to render, not a count.")
if 0 < stop_at < n:
print(f"[{TAG}] render_through={stop_at}: rendering hops 1-{stop_at} "
f"of {n}; the rest of the plan is untouched", flush=True)
blocks = blocks[:stop_at]
shots = shots[:stop_at]
n = stop_at
elif stop_at > n:
print(f"[{TAG}] render_through={stop_at} is past the end of a "
f"{n}-hop plan; rendering all of it", flush=True)
if start_at > n:
print(f"[{TAG}] render_from={start_at} is past the end of a "
f"{n}-hop plan; starting at hop 1", flush=True)
start_at = 0
replay_before = max(0, start_at - 1)
if replay_before:
# Checked here rather than beside the hop store, which is built much
# further down -- after the master tensor, which at 8 x 15 s and
# 1280x736 is ~31 GB. A misconfigured range must not cost that
# allocation before it is told it is misconfigured.
if dry:
raise ValueError(
f"{TAG}: render_from={start_at} needs the hop cache, and a "
"dry run never touches it. Use render_through to limit what "
"a dry run compiles.")
if str(cache_hops) != "on":
raise ValueError(
f"{TAG}: render_from={start_at} replays hops 1-"
f"{replay_before} from the hop cache, so cache_hops must be "
"on. With it off there is nothing to replay from, and the "
f"join into hop {start_at} would be invented rather than "
"continued.")
print(f"[{TAG}] render_from={start_at}: hops 1-{replay_before} come "
f"from the cache, {start_at}-{n} render", flush=True)
# Per-shot duration overrides, validated up front so a bad value fails
# before any sampling happens rather than three hops in.
lengths = []
for i, sh in enumerate(shots):
dur = (sh or {}).get("duration") if sh else None
if dur and str(dur) not in DURATION_FRAMES:
raise ValueError(
f"{TAG}: shot {i + 1}: duration '{dur}' is not valid. "
f"Use one of: {', '.join(DURATION_FRAMES)}"
)
lengths.append(align_frame_count(_duration_frames(dur or duration)))
if str(hop_script) == "next":
for i, sh in enumerate(shots):
if i == 0:
continue
join = ((sh or {}).get("directives") or {}).get("join")
dur = ((sh or {}).get("duration") if sh else None) or duration
if join == "continuous" and str(dur) == "5 s":
print(
f"[{TAG}] note: shot {i + 1} is join=continuous at 5 s "
"(124f). That budget drops the airlock; 8 s / 15 s is "
"the join-validation canvas. A lucky seed can still "
"join at 5 s.",
flush=True,
)
_validate_anchors(shots, start_image_file)
_validate_last_frame_guide(last_frame_guide, start_image_file)
try:
_rp_for_refs = _refs.parse_ref_plan(ref_plan)
except Exception:
_rp_for_refs = None
if _rp_for_refs is not None:
_plan.validate_shot_refs(shots, _rp_for_refs.get("refs"))
# Same rule, same reason: checked on the queue, by filename, before any
# media is loaded. A chain that cannot reach its anchor should say so in
# a second rather than nine hops later.
if (str(tone_compensate) == "anchor" and float(tone_anchor) > 0.0
and str(tone_anchor_ref) == "still"
and not str(start_image_file or "").strip()):
raise ValueError(
f"{TAG}: tone_anchor_ref=still but no start image is set. That "
"mode holds the chain on the photograph's colour and contrast; "
"without one there is nothing to hold. Set start_image_file in "
"MEDIA, or use tone_anchor_ref=hop1.")
# pin_mech=motion_context: both chain-wide preconditions checked before
# any sampling, so a forced setting fails on the queue rather than three
# hops in. The per-hop one (no sampler latent) cannot be known here and
# is raised by _pin_mech_for at the hop it affects.
if str(pin_mech) == "motion_context":
if _motion_context_cls() is None:
raise ValueError(
f"{TAG}: pin_mech=motion_context but ComfyUI-H3-Motion-Context "
"is not installed. Install it, or use pin_mech=auto for the "
"AddGuide pixel pin."
)
if str(overlap_n) not in MC_CONTEXT_LENGTHS:
raise ValueError(
f"{TAG}: pin_mech=motion_context but overlap {overlap} "
f"({overlap_n} frames) has no Motion-Context context_length. "
f"It accepts {', '.join(sorted(MC_CONTEXT_LENGTHS, key=int))} "
"frames; pick an overlap with one of those, or use pin_mech=auto."
)
for i, ln in enumerate(lengths):
if overlap_n >= ln:
raise ValueError(
f"{TAG}: shot {i + 1}: overlap {overlap} ({overlap_n} frames) must "
f"be smaller than duration ({ln} frames)"
)
state = _parse_state(continuity_state)
# The three singles first, so a name that does not resolve is reported
# before anything expensive starts. Each returns exactly what the socket
# it replaced delivered, so everything downstream is unchanged.
start_image = _media.load_image(start_image_file) if start_image_file else None
# max_frames: core truncates a reference clip to the hop's frame count
# (`frames[:frame_count]` in nodes_minimax_h3.py) AFTER decoding all of
# it, so a 60 s plate on a 10 s hop decodes 1440 frames to use 243. The
# longest hop is the upper bound -- never the shortest, or a clip would
# be cut before core had the chance not to need it.
reference_video = (_media.load_video(
reference_video_file,
max_frames=max(lengths) if lengths else length,
start=float(reference_video_start_s), end=float(reference_video_end_s),
size=reference_video_size or _media.DEFAULT_VIDEO_SIZE)
if reference_video_file else None)
voice = (_media.load_audio(voice_file,
start=float(voice_start_s), end=float(voice_end_s))
if voice_file else None)
# Empty string is off. The lock is not entered, the cache key does
# not grow a new field, delivered audio stays generated. That is
# the byte-identical claim.
locked = (_prepare_master_audio(master_audio_file)
if str(master_audio_file or "").strip() else None)
# Slots 2 and 3. All three decode at the same reference_video_size --
# it is an area budget for the decode, not a per-clip creative choice,
# and three of them is already three times the RAM.
def _more_video(fname, t0, t1):
return (_media.load_video(
fname, max_frames=max(lengths) if lengths else length,
start=float(t0), end=float(t1),
size=reference_video_size or _media.DEFAULT_VIDEO_SIZE)
if fname else None)
def _more_voice(fname, t0, t1):
return (_media.load_audio(fname, start=float(t0), end=float(t1))
if fname else None)
reference_video_2 = _more_video(reference_video_2_file,
reference_video_2_start_s, reference_video_2_end_s)
reference_video_3 = _more_video(reference_video_3_file,
reference_video_3_start_s, reference_video_3_end_s)
voice_2 = _more_voice(voice_2_file, voice_2_start_s, voice_2_end_s)
voice_3 = _more_voice(voice_3_file, voice_3_start_s, voice_3_end_s)
# Each clip's own soundtrack, paired to the same ordinal. Core takes
# ref_video_audios beside ref_videos and this pack never passed them,
# so a reference clip reached the model silent even when the file had
# sound. Trimmed to the same window as its picture.
def _clip_audio(fname, t0, t1):
if not fname:
return None
try:
return _media.load_audio(fname, start=float(t0), end=float(t1),
kinds=("audio", "video"))
except Exception as e: # noqa: BLE001 -- a silent clip is not a failure
print(f"[{TAG}] reference clip {fname}: no usable audio track "
f"({e!r}); passing it silent", flush=True)
return None
_vid_slots = [
(reference_video, _clip_audio(reference_video_file,
reference_video_start_s, reference_video_end_s)),
(reference_video_2, _clip_audio(reference_video_2_file,
reference_video_2_start_s, reference_video_2_end_s)),
(reference_video_3, _clip_audio(reference_video_3_file,
reference_video_3_start_s, reference_video_3_end_s)),
]
for _name, _got in (("reference_video_2", reference_video_2_file and reference_video_2 is None),
("reference_video_3", reference_video_3_file and reference_video_3 is None),
("voice_2", voice_2_file and voice_2 is None),
("voice_3", voice_3_file and voice_3 is None),
("start_image", start_image_file and start_image is None),
("reference_video", reference_video_file and reference_video is None),
("voice", voice_file and voice is None),
("soundtrack", soundtrack_file and soundtrack is None
and _media.resolve(soundtrack_file, kinds={"audio"}) is None)):
if _got:
print(f"[{TAG}] note: {_name} file could not be read; continuing "
f"without it", flush=True)
ref_plan_obj = _refs.parse_ref_plan(ref_plan)
ref_plan_refs = ref_plan_obj["refs"]
ref_subjects = ref_plan_obj["subjects"]
# Derived slot -> tensor. The register still needs to know which picture
# a @tag is pinned to; the slot is now the ref's position in the rail
# rather than a socket number, and _collect_ref_images below dense-packs
# and throws it away.
slot_images = {}
for _r in ref_plan_refs:
if not _r["file"]:
continue
_im = _media.load_image(_r["file"], cap_mp=_r.get("mp") or 0.0)
if _im is not None and _im.shape[0] > 0:
slot_images[_r["slot"]] = _im[:1]
if ref_plan_refs:
print(f"[{TAG}] reference register:", flush=True)
print(_refs.describe(ref_plan_obj), flush=True)
# A named picture that is not on disk stops the queue. Warning and
# continuing renders the whole chain with that reference silently
# inactive, which is the failure the register exists to prevent --
# and the docs have always promised a stop here.
_absent = _refs.missing_files(ref_plan_obj, set(slot_images))
if _absent:
raise ValueError(
f"[{TAG}] reference picture not found in "
f"ComfyUI/input/h3_refs: "
+ "; ".join(f"@{_t} names '{_f}'" for _t, _f in _absent)
+ ". Drop the file onto that row in the REFERENCES rail, or "
"clear the row's picture to render without it.")
for _w in _refs.check(ref_plan_obj, set(slot_images)):
print(f"[{TAG}] note: {_w}", flush=True)
# ref_plan subjects and a `characters` block in continuity_state
# both feed the prompt header, so filling in both injects identity
# prose twice. HTCContinuityState no longer emits characters, so this
# can only come from hand-authored JSON -- still worth warning about
# rather than raising, since the setting half stays useful.
_chars = sorted(
cid for cid, c in (state.get("characters") or {}).items()
if (c or {}).get("locked") or (c or {}).get("context")
or (c or {}).get("mutable")
)
if ref_subjects and _chars:
print(f"[{TAG}] note: ref_plan defines subject(s) "
f"{sorted(ref_subjects)} and continuity_state also carries "
f"character(s) {_chars}. Both inject identity text -- drop "
f"the characters block and keep setting only.", flush=True)
ref_images = _collect_ref_images(slot_images)
base_videos, base_video_audios = {}, {}
for _v, _a in _vid_slots:
if _v is None:
continue
_n = len(base_videos) + 1
base_videos[f"ref_video_{_n}"] = _v
if _a is not None:
base_video_audios[f"ref_video_audio_{_n}"] = _a
base_videos = base_videos or None
base_video_audios = base_video_audios or None
if base_videos and len(base_videos) > 1:
print(f"[{TAG}] {len(base_videos)} reference clips"
+ (f", {len(base_video_audios)} with sound" if base_video_audios else "")
, flush=True)
# The author's clip is always <Video 1>: _attach_pin_to_qwen APPENDS the
# pinned tail (`live_v = len(videos) + 1`), so unlike the stills nothing
# shifts it. One line, built once, and empty unless the field is filled.
refvid_line = (_refvid_cite(reference_video_desc, 1)
if reference_video is not None else "")
if refvid_line:
print(f"[{TAG}] reference clip described: {refvid_line}", flush=True)
elif reference_video is not None:
print(f"[{TAG}] note: a reference clip is wired but has no "
"description, so it goes to the encoder as <Video 1> with "
"nothing saying why. Fill reference_video_desc if the render "
"keeps drifting toward the clip.", flush=True)
ref_audios = _dense_media("ref_audio_", [voice, voice_2, voice_3])
if ref_audios and len(ref_audios) > 1:
print(f"[{TAG}] {len(ref_audios)} voice references", flush=True)
# Fingerprint the model as it arrives -- after whatever LoRA and
# attention nodes are drawn upstream, before this node touches it.
# Skipped on a dry run: it only feeds the hop cache key, and a dry run
# writes no cache. Hashing patched weights is not free.
model_fp = None if dry else _model_fingerprint(model)
sampler = base_sigmas = None
sigma_cache = {}
if not dry:
model = _result(_core_call(
MiniMaxH3SigmaShift, "the sigma shift",
model=model, shift_video=float(shift_video),
shift_audio=float(shift_audio)))[0]
sampler = _result(_core_call(
KSamplerSelect, "the sampler",
sampler_name=sampler_name))[0]
base_sigmas = _result(_core_call(
BasicScheduler, "the sigma schedule",
model=model, scheduler=scheduler, steps=int(steps),
denoise=1.0))[0]
sigma_cache = {int(steps): base_sigmas}
print(
f"[{TAG}] {n} hop(s), {length}f ({length / FPS:.1f}s) @ {width}x{height} "
f"({resolution}, {aspect}), overlap {overlap_n}f, "
f"hop_script={hop_script}, pin_to_qwen={pin_to_qwen}, "
f"{unique} authored block(s), {steps} steps {sampler_name}/{scheduler}",
flush=True,
)
# Preallocate the master instead of growing it with torch.cat. cat
# allocates a fresh full-size tensor every hop, so at hop N the old and
# new masters are briefly live *together* alongside prev_imgs and imgs.
# Total length is known up front, so one allocation plus slice-writes
# removes that doubling.
#
# A restart is a chain start: it overlaps with nothing, so it must
# not be charged an overlap trim. `sum(lengths) - overlap * (n - 1)`
# assumed every hop past the first was trimmed and silently dropped
# 0.9 s of new content per restart.
hop_starts = [
i == 0 or str((sh or {}).get("anchor") or "") == "restart"
for i, sh in enumerate(shots)
]
total_frames = _alock.master_frame_count(lengths, overlap_n, hop_starts)
n_trims = sum(1 for flag in hop_starts if not flag)
if n_trims != n - 1:
print(f"[{TAG}] master length {total_frames}f "
f"({n - n_trims} chain start(s), {n_trims} overlap trim(s); "
f"old formula would have been "
f"{sum(lengths) - overlap_n * (n - 1)}f)",
flush=True)
# Is the take long enough for the chain it is locked to?
#
# Every other duration in this pack is validated on the queue, and this
# one was not. `_prepare_master_audio` loads the file, prints how long
# it is, and nothing ever compares that to the chain. A take shorter
# than the render runs the last hops past its end, `fit_samples`
# zero-pads them, and those hops come back MUTE -- discovered after
# paying for the render.
#
# It is the defect the review of the contributed patch listed third
# ("`master_audio_secs` is computed, printed, and never used again"),
# and rebuilding that feature from its prose reproduced it faithfully.
# It was also hit during this project's own GPU testing and worked
# around by hand, which is the clearest possible argument for a check.
#
# Raises rather than warns. Trailing silence is expressible -- pad the
# take file -- but a mute final hop that nobody asked for is not worth
# the minutes it costs to find out about.
if locked is not None:
take_s = float(locked["wav"].shape[-1]) / float(locked["sr"])
need_s = float(total_frames) / FPS
if take_s + 1.0 / FPS < need_s:
raise ValueError(
f"{TAG}: master_audio_file is {take_s:.2f}s but this chain "
f"is {need_s:.2f}s ({total_frames}f at {FPS:g} fps). The "
f"last {need_s - take_s:.2f}s would be locked to silence "
f"the take does not contain. Shorten the chain, or pad the "
f"recording to at least {need_s:.2f}s.")
if take_s > need_s + 1.0:
print(f"[{TAG}] master_audio_file is {take_s:.2f}s for a "
f"{need_s:.2f}s chain; the last {take_s - need_s:.2f}s "
f"is not used", flush=True)
# A dry run must not allocate the master. At 8 x 15 s and 1280x736 that
# is 2742 full float frames -- ~31 GB -- for a feature whose entire
# point is that it costs seconds.
master_imgs = None if dry else _alloc_master(total_frames, height, width)
write_pos = 0
# Where each join actually landed. Recorded rather than derived,
# because it can no longer BE derived: `seam.seam_positions()` solves
# for a uniform hop length from (total, hops, overlap), and a restart
# hop writes its full length instead of being trimmed. On a 4-hop chain
# with a restart on hop 4 that estimate lands on 198/373/548 where the
# joins are at 192/362/532 -- it measures the middle of hops and calls
# them seams. The node that writes the frames is the only thing that
# knows for certain, so it says so on `info`.
seam_marks = []
master_wav = None
sr = None
prev_imgs = None
prev_audio = None
prev_sampled = None
pbar = comfy.utils.ProgressBar(n)
hop_store = None
# "on" is what pre-0.5 workflows saved for what is now "sigma".
pin_renorm_mode = {"on": "sigma"}.get(str(pin_renorm), str(pin_renorm))
if pin_renorm_mode not in ("sigma", "band"):
pin_renorm_mode = "off"
pin_noise_v = max(0.0, min(0.10, float(pin_noise)))
audio_ctx = int(audio_pin_frames) if int(audio_pin_frames) > 0 else int(overlap_n)
pin_anchor = None # the first pinned hop sets what 3+ match
if pin_renorm_mode != "off" or pin_noise_v > 0.0:
print(f"[{TAG}] pin conditioning enabled: "
f"renorm={pin_renorm_mode} "
f"noise={pin_noise_v:.3f}", flush=True)
tone_mode = str(tone_compensate)
tone_on = tone_mode != "off" and tone_mode in _tone.MODES
if tone_on:
print(f"[{TAG}] tone compensation: {tone_mode} "
f"(overlap {overlap_n}f)", flush=True)
if str(cache_hops) == "on" and not dry:
import folder_paths
hop_store = _store.HopStore(
os.path.join(folder_paths.get_temp_directory(), "h3_ref_chain_hops"),
budget_gb=float(cache_budget_gb), fps=FPS)
# The fingerprint is printed because it is the one cache input a
# user cannot see and cannot derive. If a run that should have hit
# re-rendered everything, this line moving between two runs says so
# in one glance -- and a node that mutates a public scalar attribute
# on itself between queues (see `_object_scalars`) is exactly the
# case that would otherwise look like the cache is simply broken.
print(f"[{TAG}] hop cache: {hop_store.root} "
f"(budget {float(cache_budget_gb):.0f} GB, model {model_fp})",
flush=True)
# Note on how render_from works, since this is where the store appears:
# the leading hops are NOT seeded into prev_imgs / prev_audio /
# prev_sampled / prev_key from here. They run through the loop like any
# other hop and are simply required to hit the cache. The hit branch
# already carries all four forward exactly as a render does -- the
# sampler latent especially, which decides whether the next hop joins by
# Motion-Context or falls back to AddGuide -- and a second copy of that
# logic is a second thing to get subtly and silently wrong.
# Everything constant across the chain, mixed into every hop key so a
# resolution or sampler change invalidates the whole cache.
chain_salt = {
"w": int(width), "h": int(height),
# No "overlap" here, for the reason "pin_mech" is not here either
# and "pin_cond" is keyed only from hop 2: the trim and the pin are
# both hop-2+ work (`hop 2: dropped 22 frames`, and _pin_mech_for
# returns "none" for index 0), so hop 1's pixels cannot depend on
# it. Keyed chain-wide it threw away a byte-identical cached hop 1
# on every overlap A/B -- half the cost of the test, on the hop the
# lever does not reach.
"sampler": str(sampler_name), "scheduler": str(scheduler),
"shift_v": float(shift_video), "shift_a": float(shift_audio),
"ref_size": str(ref_image_size),
# No "pin" here either, for the same reason as "overlap" above:
# `_attach_pin_to_qwen` is called only under `if i > 0`, so
# pin_to_qwen cannot reach hop 1's pixels. Keyed chain-wide it threw
# away a byte-identical cached hop 1 on every pin_to_qwen A/B. It is
# in the per-hop key below, from hop 2.
# No "pin_mech" here: the mechanism is decided per hop at runtime
# in _pin_continue (Motion-Context when a sampler latent exists,
# AddGuide pixels otherwise), so it belongs in the per-hop key
# below, not in the chain-wide salt.
# No "refs" here any more -- they are keyed per hop below.
#
# Digesting every wired reference chain-wide meant swapping the file
# behind @outfit moved hop 1's key even when @outfit rides only hop
# 5, and because the key is chained that re-rendered the entire
# chain. Changing one late reference cost a full run. A reference
# can only change the pixels of a hop it is actually handed to, so
# that is where it belongs.
# All three slots, not just the first. Digesting only slot 1 would
# let a chain rendered with a second voice be served to a run that
# dropped it -- the silently-wrong-frames class this key exists to
# prevent.
"voice": [_store.audio_digest(v) for v in (voice, voice_2, voice_3)],
"refvid": [_store.tensor_digest(v) for v in
(reference_video, reference_video_2, reference_video_3)],
"refvid_audio": sorted(base_video_audios or {}),
# tensor_digest already covers the pixels, and the pixels change
# with the size -- but only once the clip is decoded. Naming the
# setting keeps the key readable when a cache miss has to be
# explained to somebody.
"refvid_size": str(reference_video_size or _media.DEFAULT_VIDEO_SIZE),
"start": _store.tensor_digest(start_image),
# A cached hop rendered under different LoRAs or a different
# attention path is not the same hop, so what has been patched onto
# the incoming model is part of the key. See _model_fingerprint.
"model": model_fp,
}
if locked is not None:
# Only present when the lock is on. Adding a None field while
# off would move every existing cache key and break the
# empty-string byte-identical claim.
chain_salt["master_audio"] = locked["digest"]
prev_key = None
hop_keys = []
# tone_compensate=anchor state. `anchor_ref` is the Lab look every hop
# is eased back toward. A shot with tone="rebase" moves the reference
# onto itself, which is how a scene that is genuinely darker from here
# on stops being fought.
#
# Two places it can come from. `hop1` is the original: the first hop's
# own look, the one tone in the chain nothing has drifted into yet.
# `still` is start_image, and for a long chain it is the better target,
# because hop 1 is NOT the reference -- an outside 9-hop study measured
# the photograph at chroma 33.6 and hop 1 at 30, b* 26.6 against 22,
# before any relay had happened. Anchored on hop 1 the chain holds a
# target that already fell short; anchored on the still it holds the
# thing the user actually chose. The still also lets hop 1 itself be
# pulled, which the hop1 mode cannot do by construction.
anchor_on = tone_mode == "anchor" and float(tone_anchor) > 0.0
anchor_from_still = anchor_on and str(tone_anchor_ref) == "still"
anchor_ref = None
if anchor_from_still:
anchor_ref = _tone.anchor_stats(start_image)
print(f"[{TAG}] tone anchor set from the start image: "
+ _tone.anchor_note(anchor_ref), flush=True)
sheet_rows = []
assembled = []
for i, block in enumerate(blocks):
mm.throw_exception_if_processing_interrupted()
shot = shots[i] or {}
hop_length = lengths[i]
print(f"[{TAG}] hop {i + 1}/{n}...", flush=True)
# Computed HERE, not beside the sampler where it used to be. A
# restart is a chain start, and almost everything that makes a hop
# a start rather than a continuation -- which references ride it,
# whether the previous frame is pinned as <Picture 1>, whether the
# prompt says "opens already in progress" -- is decided in the two
# hundred lines below this point. Deciding `hop_restart` after all
# of them meant a restart hop was assembled as a continuation and
# only then handed the photograph, which is how it ended up being
# told it continues from pinned frames it does not have.
hop_restart = i > 0 and str(shot.get("anchor") or "") == "restart"
hop_is_start = i == 0 or hop_restart
if hop_restart:
print(f"[{TAG}] hop {i + 1}: ANCHOR RESTART -- start image is "
f"frame 0, the previous hop is not relayed", flush=True)
_push_preview(unique_id, f"hop {i + 1}/{n} sampling…", hop=i + 1, total=n,
frac=(write_pos / float(total_frames)) if total_frames else None)
# With a register, this hop's refs are only the ones active on it.
# shot.refs, when present, is the whole rail for this hop --
# including the empty list, which is how you drop identity stills
# on a pin-less restart without changing the rest of the chain.
# Omitted keeps the register default: unscheduled stills ride
# chain starts and stay off continuations under hop_script=next.
hop_active = []
hop_subject_prose = ""
if ref_plan_refs:
shot_refs = shot.get("refs")
if shot_refs is not None:
hop_active = _refs.select_for_shot(
ref_plan_refs, shot_refs, set(slot_images))
print(
f"[{TAG}] hop {i + 1}: shot.refs "
+ (", ".join("@" + t for t in shot_refs) or "(none)"),
flush=True,
)
else:
hop_active = _refs.active_refs(
ref_plan_refs, i, set(slot_images))
# Continuation: omit shots[] = hop 1 only. Face/outfit plates
# of a different room (chain_00034) beat the pin as Pictures 1–3
# and hop 2 opened a new Ref2VA generate — commercial kitchen,
# apron gone. List hop numbers on a ref to ride later hops.
if not hop_is_start and str(hop_script) == "next":
dropped = [r["tag"] for r in hop_active if r.get("shots") is None]
hop_active = [r for r in hop_active if r.get("shots") is not None]
if dropped:
print(
f"[{TAG}] hop {i + 1}: unscheduled stills stay off "
f"this continue ({', '.join('@' + t for t in dropped)}); "
f"pin carries wardrobe and room",
flush=True,
)
base_images = {
f"ref_image_{k + 1}": slot_images[r["slot"]]
for k, r in enumerate(hop_active)
} or None
else:
base_images = ref_images
if not hop_is_start and str(hop_script) == "next":
print(
f"[{TAG}] hop {i + 1}: identity stills stay off this "
"continue (no shots[] schedule); pin carries wardrobe "
"and room",
flush=True,
)
base_images = None
hop_images = base_images
hop_videos = base_videos
live_p = live_v = None
still_shift = 0
# Not on a restart. Pinning the previous hop's last frame as
# <Picture 1> is the definition of a continuation, and a restart
# takes NOTHING from the previous hop -- the branch at the sampler
# says so in as many words. It was still handing over the tail here.
if not hop_is_start:
hop_images, live_p, hop_videos, live_v = _attach_pin_to_qwen(
str(pin_to_qwen), base_images, base_videos,
prev_imgs[-1:], prev_imgs[-overlap_n:],
)
if live_p == 1:
still_shift = 1
if not hop_images:
hop_images = None
if not hop_videos:
hop_videos = None
# One ordinal map for this hop. The prompt's <Picture N> citations
# and the identity lock's ordinals have to agree, and computing the
# same shift twice is exactly how they drift apart. `p`, not `n` --
# `n` is the hop count in the enclosing scope.
hop_ords = _refs.ordinals(hop_active)
if still_shift:
hop_ords = {t: p + still_shift for t, p in hop_ords.items()}
if ref_plan_refs:
# Plan-wide subjects so @hero_face still resolves when that
# photograph is off this hop.
# Names, not ordinals, from hop 2 on. `subject_definitions:`
# is hop-1 material, so a `<Subject N>` reaching hop 4 has no
# antecedent in its own encode -- the dangling-token defect
# that turned an undescribed "the bowl" into a steel one.
# `continuity_line` rides every continuation hop and is what
# the name binds to.
block = _refs.resolve_tags(
block, hop_ords, _refs.subjects(ref_plan_refs),
where=f"shot {i + 1}",
declared={r["tag"] for r in ref_plan_refs},
subject_names=({k: (v or {}).get("name")
for k, v in (ref_subjects or {}).items()}
if i > 0 else None))
if i == 0:
# Hop 1 only: subject_prose derives its own ordinals with no
# still_shift, correct only because hop 1 has no pin.
# Computing it on hop 2+ invites an off-by-one against the
# live frame.
hop_subject_prose = _refs.subject_prose(hop_active, ref_subjects)
if hop_is_start and hop_subject_prose and not _d.is_full_h3_prompt(block):
block = hop_subject_prose + "\n\n" + block
# Hop 1 has no _assemble_next to fold this into. A full H3 block is
# the author's own prompt end to end, so it is left alone there --
# the same rule subject_prose follows two lines up.
if hop_is_start and refvid_line and not _d.is_full_h3_prompt(block):
block = block.rstrip() + "\n\n" + refvid_line
if str(hop_script) == "next" and not hop_is_start:
n_stills = len(base_images or {})
hop_state_header = _state_header(state, i)
# With a register wired, only the subject-bearing refs are
# identities; a setting or prop plate must not be declared one.
id_ords = None
n_subj = None
if hop_active:
# `subject is not None` on its own is not enough. The
# canonical outfit ref (README) carries a subject AND
# retention `partially_copy`, so it was being declared "the
# only identity ... that face, bone structure, and hairstyle
# match the photograph exactly" -- asserted about a
# photograph of a garment. Only a fully_preserved still is a
# face plate. refs.py already defaults a subject-bearing ref
# to fully_preserved, so an ordinary identity reference is
# unaffected by this narrowing; a wardrobe or setting plate
# stops being called a person.
faces = [r for r in hop_active
if r["subject"] is not None
and r["retention"] == "fully_preserved"]
id_ords = [hop_ords[r["tag"]] for r in faces]
# Counted over this hop, not the whole plan. Counting
# plan-wide while listing only this hop's ordinals is how a
# single scheduled still produced "<Picture 2> are the only
# identities" -- the plural that has rendered two people
# from one reference.
n_subj = len({r["subject"] for r in faces}) or None
elif ref_plan_refs:
# Pin-only hop: no identity to lock. _identity_lock returns
# "" on an empty ordinal list whatever the count says.
id_ords = []
# Plan-wide, not per-hop: a character in the chain is in
# the chain whether or not their photograph rides this hop.
# This is the text that has to survive hop 5 of the showcase,
# which schedules no references at all.
hop_continuity = _refs.continuity_line(
ref_subjects,
{r["subject"] for r in ref_plan_refs
if r["subject"] is not None}) if ref_plan_refs else ""
# What each still riding THIS hop is for. Hop 1 gets this from
# subject_prose; without it here a scheduled still reaches the
# encoder as an uncited photograph with no stated role, and a
# Ref2VA model handed a picture and no reason for it renders the
# picture. `hop_ords`, not fresh ordinals -- the live frame is
# <Picture 1> on every continuation hop.
hop_retention = _refs.retention_prose(hop_active, hop_ords)
hop_wardrobe = any(r["retention"] == "partially_copy"
for r in hop_active)
block = _assemble_next(
block,
live_picture=live_p,
live_video=live_v,
n_stills=n_stills,
state_header=hop_state_header,
identity_ordinals=id_ords,
n_subjects=n_subj,
tail=(shot.get("directives") or {}).get("tail"),
continuity=hop_continuity,
retention=hop_retention,
wardrobe=hop_wardrobe,
refvid=refvid_line,
)
print(f"[{TAG}] hop {i + 1} next-beat assembled "
f"(Picture {live_p}, Video {live_v}, "
f"{len(id_ords or [])} identity stills of {n_stills}, "
f"state_header {len(hop_state_header)} chars, "
f"continuity {len(hop_continuity)} chars, "
f"retention {len(hop_retention)} chars"
f"{', wardrobe plate' if hop_wardrobe else ''})",
flush=True)
elif not hop_is_start and hop_active:
# Verbatim mode assembles nothing -- the author owns the text --
# so the retention block above is not injected here. But a still
# scheduled onto this hop and never named in it reaches the
# encoder as the same uncited photograph, authored rather than
# assembled. Say so rather than silently rendering it.
uncited = [r["tag"] for r in hop_active
if f"<Picture {hop_ords[r['tag']]}>" not in block]
if uncited:
print(f"[{TAG}] hop {i + 1}: "
+ ", ".join("@" + t for t in uncited)
+ " rides this hop but is never cited in its prompt. "
"An uncited reference tends to be rendered as the "
"shot; name it with its @tag, or take it off this "
"hop in the rail.", flush=True)
# Voice is hop-1 only under hop_script=next. The pin already
# carries hop 1's spoken audio; leaving the clip on hop 2 as a
# second <Audio 1> with no line to attach to is what put a
# 1.35 s male take into the last second of chain_00038 while
# the written line still followed the woman's face.
# `voice_every_hop` lifts it deliberately; `_voice_rides_hop`
# holds the reasoning and why `speaking` is not the same risk
# as `on`.
_voice_lift = _voice_rides_hop(voice_every_hop, block)
hop_voice = voice is not None and (
hop_is_start or str(hop_script) != "next" or _voice_lift)
if not hop_is_start and voice is not None and not hop_voice:
_why = ("no spoken line in this beat"
if str(voice_every_hop) == "speaking"
else "pin carries the spoken audio; "
"voice_every_hop=speaking rides it")
print(
f"[{TAG}] hop {i + 1}: voice ref stays off this continue "
f"({_why})",
flush=True,
)
elif not hop_is_start and voice is not None and _voice_lift:
print(
f"[{TAG}] hop {i + 1}: voice ref rides this continue "
f"(voice_every_hop={voice_every_hop})",
flush=True,
)
if hop_voice and "<Audio 1>" not in block:
block = (
block.rstrip()
+ "\n\nThe speaker's voice follows <Audio 1> as a "
"timbre reference."
)
assembled.append((i + 1, block))
# A dry run has everything it came for the moment the block is
# compiled: this is the text the encoder would receive. Stop here,
# before the key, the cache and the sampler.
if dry:
sheet_rows.append({
"hop": i + 1,
"first": None, "last": None,
"beat": (shot.get("beat") or "").strip() or "(continues)",
"directives": dict(shot.get("directives") or {}),
"meta": [f"{hop_length}f ({hop_length / FPS:.1f}s)",
f"{len(block)} chars compiled",
f"{len(hop_active)} ref(s)" if hop_active else None,
# NOT the pin mechanism: which one a hop gets is
# decided at render time by whether a sampler
# latent exists, and a dry run has none. Reporting
# the setting is honest; reporting AddGuide for
# every hop would not be.
f"pin_to_qwen={pin_to_qwen}" if i > 0 else None,
f"tone={shot.get('tone')}" if shot.get("tone") else None],
})
# The next hop's prompt asks how many pictures precede it, not
# what is in them, so a token stand-in compiles identical text
# for none of the memory.
prev_imgs = torch.zeros((max(overlap_n, 1), 8, 8, 3),
dtype=torch.float32)
pbar.update(1)
continue
# Key this hop. prev_key makes the key chained, so editing shot 1
# invalidates every hop after it -- correct, and the reason the UI
# must show staleness before queuing or it reads as a bug.
hop_key = None
cached = None
# A restart outranks a forced `pin_mech`: it relays nothing, so
# there is no mechanism left to pick. Asking for motion_context on
# a restart hop is not a contradiction to raise on, it is a setting
# that does not reach this hop.
pin_mech_pred = ("none" if hop_restart
else _pin_mech_for(i, overlap_n, prev_sampled,
mode=str(pin_mech)))
pin_mech_used = pin_mech_pred
if hop_store is not None:
# `None`, not prev_key, on a restart: the hop genuinely does
# not depend on its predecessor, so chaining it would re-render
# every restart whenever anything earlier moved -- and later
# hops should chain from the restart, which they do because
# this key becomes their prev_key.
hop_payload = {
"chain": chain_salt,
"block": block,
"len": hop_length,
"steps": int(shot.get("steps") or steps),
"seed": (int(shot["seed"]) if shot.get("seed") is not None
else ((int(seed) + i) if seed_per_shot else int(seed))),
"tags": [r["tag"] for r in hop_active],
# The reference PIXELS this hop is handed, not the whole
# rail (see chain_salt). `base_images` is the pre-pin dict,
# which is the right thing on both paths: with a ref plan it
# is this hop's scheduled stills, without one it is every
# wired ref, and the pin frame it excludes is already
# accounted for by `prev_key`.
"refs": {k: _store.tensor_digest(t)
for k, t in sorted((base_images or {}).items())},
# Per hop, not chain-wide: hop 2 after a hop-1 cache hit
# has no sampler latent and falls back to AddGuide, which
# is a different render of the same inputs.
"pin_mech": pin_mech_pred,
# Explicit, not implied by pin_mech="none": a restart also
# drops prev_key, and a key must say what produced it.
"restart": hop_restart,
# Only from hop 2. Hop 1 has no pin -- `_pin_mech_for`
# returns "none" for index 0 and the conditioning branch is
# `elif i > 0` -- so its frames cannot depend on these
# levers, and keying them in threw away a byte-identical
# cached hop 1 every time one was flipped. That is a third
# of the cost of every lever A/B, on the one hop nobody
# needed to re-render.
"pin_cond": ((pin_renorm_mode, round(pin_noise_v, 4), audio_ctx)
if not hop_is_start else None),
# Same rule, moved out of chain_salt: how many frames the
# previous hop hands over changes this hop's conditioning
# and its trim, and nothing on a chain start. A restart
# is a start -- it neither pins nor trims -- so overlap
# cannot reach its pixels. `i > 0` here used to re-render
# every restart whenever overlap moved.
"overlap": (overlap_n if not hop_is_start else None),
# Tone, and the same "only from hop 2" rule again. The
# comment above `hop_store.put` explains why the mode is
# kept out of the key: the cache holds RAW hops, corrected
# afterwards, so switching modes need not invalidate them.
# That is true of the hop it corrects and false of the next
# one, because the corrected frames are what `prev_imgs`
# becomes and `prev_imgs` is the pin. Hop 2 rendered under
# anchor=still is a different render from hop 2 under
# anchor=off, and without this it was served either way --
# its key was byte-identical. Hop 1 has no pin, so it still
# survives every tone switch, which is the half of that
# claim that was always true.
"tone": ((tone_mode, round(float(tone_anchor), 4),
str(tone_anchor_ref)) if not hop_is_start else None),
# Same rule. `_attach_pin_to_qwen` runs only when this hop
# has a predecessor pin, so a restart (hop_is_start) is
# keyed like hop 1: the setting cannot reach its pixels.
"pin_qwen": (str(pin_to_qwen) if not hop_is_start else None),
# Whether this hop actually received the voice tensor.
# chain_salt already digests the file; without this a hop 2
# rendered with the clip on would be served to a later run
# that kept it off.
"voice_on": hop_voice,
}
# last_frame_guide reaches EVERY hop, including hop 1, so it
# is not gated on hop_is_start. Only present when on: adding
# "off" would move every existing cache key and break the
# default-off byte-identical claim.
_lfg = _last_frame_guide_key_field(last_frame_guide, i, shots)
if _lfg is not None:
hop_payload["last_frame_guide"] = _lfg
hop_key = _store.hop_key(
None if hop_restart else prev_key, hop_payload)
# A locked shot reuses its last render even though its inputs
# changed -- that is the point of locking. The content key would
# have moved, so the pointer is what finds it.
shot_name = str(shot.get("id") or f"shot{i + 1}")
if shot.get("locked"):
pinned = hop_store.get_pointer(shot_name)
if pinned:
if pinned != hop_key:
print(f"[{TAG}] hop {i + 1} is locked: reusing its "
f"earlier render (inputs changed)", flush=True)
hop_key = pinned
else:
print(f"[{TAG}] hop {i + 1} is locked but has no cached "
f"render yet; rendering it once", flush=True)
hop_keys.append(hop_key)
cached = hop_store.get(hop_key)
if i < replay_before and cached is None:
# Name the hop. "Cache miss" on its own sends people to the
# temp folder to count files; what they need to know is
# which shot moved and that the sweep may simply have
# reclaimed it -- the store is under ComfyUI's temp
# directory, which is deleted on startup and shutdown.
raise ValueError(
f"{TAG}: render_from={start_at} needs hop {i + 1} in "
"the cache and it is not there. Either its inputs "
"changed since it rendered -- editing an earlier shot "
"moves every key after it -- or the cache was swept. "
f"Render hops 1-{replay_before} first, or set "
"render_from back to 0.")
this_sampled = None
if cached is not None:
imgs, wav, sr, cached_latent = cached
audio = {"waveform": wav, "sample_rate": sr}
# Carry the stored sampler latent forward exactly as a render
# would. Without this the next hop sees no latent, predicts the
# AddGuide fallback, and its key stops matching what is on disk
# -- so nothing past hop 1 could ever hit, and the hop after a
# hit was joined by the weaker mechanism.
this_sampled = cached_latent
print(f"[{TAG}] hop {i + 1}: loaded from cache "
f"({int(imgs.shape[0])}f, key {hop_key[:8]}"
f"{'' if cached_latent is not None else ', no latent'})",
flush=True)
else:
# Every argument by NAME, none by position. Passing the first
# seven positionally worked here and broke for a user on a
# different ComfyUI build: their core orders the parameters
# differently, so the seventh positional landed on
# `ref_image_size` and the keyword collided with it --
# "got multiple values for argument 'ref_image_size'", raised
# on hop 1 before anything sampled. Core's signature is not
# ours to depend on; its parameter names are the contract.
packed = _core_call(
MiniMaxH3ReferenceToVideo, "the reference conditioning",
clip=clip, vae=vae, audio_vae=audio_vae, prompt=block,
width=int(width), height=int(height), length=hop_length,
ref_image_size=ref_image_size,
ref_images=hop_images,
ref_videos=hop_videos,
# Paired by ordinal with ref_videos. The pin clip, when
# pin_to_qwen appends one, takes the next number and simply
# has no entry here -- it is the previous hop's picture and
# has no soundtrack of its own.
ref_video_audios=base_video_audios,
ref_audios=(ref_audios if hop_voice else None),
)
cond, latent = _result(packed)[0], _result(packed)[1]
if (i == 0 or hop_restart) and start_image is not None:
# A restart hop is a chain start. It gets the photograph as
# its frame-0 anchor exactly as hop 1 does, and NOTHING from
# the previous hop reaches it -- no sampler latent, no
# decoded tail. That is the entire point: every hop
# otherwise inherits its predecessor's end state, and a
# clip's end is its most settled moment, so motion and
# lighting response decay hop over hop. A restart bounds
# that accumulation to the distance between restarts
# instead of letting it run the length of the chain.
cond = _result(_core_call(
MiniMaxH3AddGuide,
"the restart anchor" if hop_restart else "the hop-1 start image",
positive=cond, latent=latent, frame_idx=0,
vae=vae, audio_vae=None,
image=start_image[:1], audio=None,
))[0]
elif i > 0 and not hop_restart:
pin_latent = prev_sampled
if pin_latent is not None:
pin_latent, pin_anchor = _condition_pin_latent(
pin_latent, pin_anchor,
mode=pin_renorm_mode, noise=pin_noise_v,
seed=(int(seed) + i))
cond, pin_mech_used = _pin_continue(
cond, latent, vae, audio_vae, overlap_n,
pin_latent, prev_imgs, prev_audio,
audio_ctx=audio_ctx, mode=str(pin_mech),
)
if (_guides_last_frame(last_frame_guide, i, shots)
and start_image is not None):
# Conservative half: pin the still at the last PIXEL
# frame. Does NOT become the next hop's frame 0 -- that
# is keyframe chaining, a v3 conversation. After the
# frame-0 / Motion-Context pin so both keyframes sit
# on `cond`; before the audio lock, which mutates the
# latent not the conditioning.
_end = _last_pixel_guide_idx()
cond = _result(_core_call(
MiniMaxH3AddGuide,
"the last-frame guide",
positive=cond, latent=latent, frame_idx=_end,
vae=vae, audio_vae=None,
image=start_image[:1], audio=None,
))[0]
print(f"[{TAG}] hop {i + 1}: last-frame guide "
f"(still at pixel frame_idx={_end})",
flush=True)
if locked is not None:
# AFTER the pin: Motion-Context may rewrite this hop's
# latent. Locking first would be overwritten. BEFORE
# the sampler: the freeze has to be in place when
# denoise runs.
_t0, _t1 = _alock.hop_audio_window_s(
i, hop_length, overlap_n, FPS,
lengths=lengths, start_at=hop_starts)
_parts = _latents.from_dict(latent)
if _parts is None or len(_parts) < 2:
raise RuntimeError(
f"{TAG}: hop {i + 1}: master_audio_file needs a "
"joint AV latent and this hop did not have one.")
_alen = int(_parts[1].shape[-1])
_z = _encode_locked_slice(
audio_vae, locked["wav32"], _t0, _t1, _alen)
latent = _splice_locked_audio(latent, _z)
print(f"[{TAG}] hop {i + 1}: audio locked "
f"[{_t0:.2f}s-{_t1:.2f}s] of master_audio_file",
flush=True)
_offload_text_encoder(clip, model)
guider = _result(_core_call(
BasicGuider, "the guider",
model=model, conditioning=cond))[0]
if shot.get("seed") is not None:
shot_seed = int(shot["seed"])
else:
shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
hop_steps = int(shot.get("steps") or steps)
if hop_steps not in sigma_cache:
sigma_cache[hop_steps] = _result(_core_call(
BasicScheduler, "the sigma schedule",
model=model, scheduler=scheduler, steps=hop_steps,
denoise=1.0))[0]
hop_sigmas = sigma_cache[hop_steps]
if hop_steps != int(steps) or shot.get("seed") is not None:
print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
f"steps={hop_steps}", flush=True)
noise = _result(_core_call(
RandomNoise, "the noise source",
noise_seed=shot_seed))[0]
sampled = _result(_core_call(
SamplerCustomAdvanced, "the sampler",
noise=noise, guider=guider, sampler=sampler,
sigmas=hop_sigmas, latent_image=latent))[0]
imgs, audio = _decode_av(vae, audio_vae, sampled)
imgs = imgs.contiguous().cpu()
wav = audio["waveform"].contiguous().cpu()
sr = int(audio["sample_rate"])
audio = {"waveform": wav, "sample_rate": sr}
if locked is not None:
# The pin of the NEXT hop must carry the take, not a
# decoded generate. Replace this hop's audio with the
# matching window of the recording.
_t0, _t1 = _alock.hop_audio_window_s(
i, hop_length, overlap_n, FPS,
lengths=lengths, start_at=hop_starts)
audio = _slice_take_audio(locked, _t0, _t1, sr)
wav = _batch_wav(audio["waveform"].contiguous().cpu())
audio = {"waveform": wav, "sample_rate": sr}
this_sampled = _latent_cpu(sampled)
del sampled, latent, cond, guider, noise
mm.soft_empty_cache()
if (hop_store is not None and hop_key is not None
and pin_mech_used != pin_mech_pred):
print(f"[{TAG}] hop {i + 1} pinned by {pin_mech_used} but its "
f"cache key says {pin_mech_pred}; not caching this hop",
flush=True)
elif hop_store is not None and hop_key is not None:
hop_store.put(hop_key, imgs, wav, sr,
{"hop": i + 1, "of": n, "block": block[:400],
"pin_mech": pin_mech_used},
latent=this_sampled)
hop_store.set_pointer(
str(shot.get("id") or f"shot{i + 1}"), hop_key)
# Tone compensation, at the one point both the render and the
# cache-hit paths have converged.
#
# It sits AFTER hop_store.put on purpose, so the cache holds raw
# hops and the mode stays out of the hop key -- switching modes
# then costs nothing instead of invalidating ~285 MB an entry.
# It sits BEFORE the master write and before `prev_imgs` is taken,
# which is the half that matters: `prev_imgs` is what feeds the next
# hop's Qwen <Picture 1> pin and the AddGuide guide image, so
# correcting here is what stops the drift compounding rather than
# merely repainting the master. Each hop is measured against the
# previous hop's ALREADY CORRECTED tail, so the whole chain lands on
# hop 1's tone.
tone_note = ""
if tone_on:
if i > 0 and prev_imgs is not None:
imgs, tone_note = _tone.compensate(
prev_imgs, imgs, tone_mode, overlap_n)
else:
# Hop 1 has nothing to match against, but the corrected hops
# come back clamped and an unclamped hop 1 beside them would
# make the master inconsistent with itself.
imgs = imgs.clamp(0.0, 1.0)
if tone_note:
print(f"[{TAG}] hop {i + 1} tone: {tone_note}", flush=True)
# The chain-wide half of `anchor`, stacked on the seam correction
# above. It runs here for the same reason that one does: `prev_imgs`
# is taken below, so correcting now is what stops the drift feeding
# the next hop rather than merely repainting the master.
if anchor_on:
shot_tone = str(shot.get("tone") or "")
if i == 0 and not anchor_from_still:
anchor_ref = _tone.anchor_stats(imgs)
if anchor_ref is not None:
print(f"[{TAG}] tone anchor set from hop 1: "
+ _tone.anchor_note(anchor_ref), flush=True)
elif shot_tone == "rebase":
anchor_ref = _tone.anchor_stats(imgs)
print(f"[{TAG}] hop {i + 1}: tone=rebase, anchor moved to "
f"this hop; later hops hold ITS level", flush=True)
elif shot_tone == "free":
print(f"[{TAG}] hop {i + 1}: tone=free, anchor pull skipped",
flush=True)
else:
# The ramp exists to keep a JOIN exact -- frame 0 of a hop
# has to equal the previous hop's last frame. Hop 1 and a
# restart hop have no such frame: one opens the chain, the
# other opens on the photograph by construction. Ramping
# them would spend two seconds fading INTO the correction
# at the exact moment the viewer is deciding what the shot
# looks like.
imgs, anchor_note = _tone.anchor_pull(
imgs, anchor_ref, strength=float(tone_anchor),
ramp=(0 if (i == 0 or hop_restart) else _tone.ANCHOR_RAMP))
if anchor_note:
tone_note = (tone_note + " + " + anchor_note
if tone_note else anchor_note)
print(f"[{TAG}] hop {i + 1} tone: {anchor_note}",
flush=True)
if hop_is_start:
keep_n = int(imgs.shape[0])
if write_pos + keep_n > total_frames:
raise ValueError(
f"{TAG}: hop {i + 1} overruns the preallocated master "
f"({write_pos + keep_n} > {total_frames}). A hop decoded a "
f"different length than planned.")
if i > 0:
seam_marks.append(int(write_pos))
master_imgs[write_pos:write_pos + keep_n] = imgs
write_pos += keep_n
if i == 0:
master_wav = wav
else:
# A cut, but 40 ms of xfade still kills the click at the
# sample boundary. Nothing is trimmed: this hop does not
# overlap the previous one.
master_wav = _xfade_audio(master_wav, wav, sr)
print(
f"[{TAG}] hop {i + 1}: restart, wrote all {keep_n} "
f"frames (no overlap trim)",
flush=True,
)
else:
if imgs.shape[0] <= overlap_n:
raise ValueError(
f"{TAG}: hop {i + 1} decoded {int(imgs.shape[0])} frames; "
f"need more than overlap {overlap_n}"
)
keep_n = int(imgs.shape[0]) - overlap_n
if write_pos + keep_n > total_frames:
raise ValueError(
f"{TAG}: hop {i + 1} overruns the preallocated master "
f"({write_pos + keep_n} > {total_frames}). A hop decoded a "
f"different length than planned.")
seam_marks.append(int(write_pos))
master_imgs[write_pos:write_pos + keep_n] = imgs[overlap_n:]
write_pos += keep_n
trimmed, dropped = _trim_audio_head(audio, overlap_n)
master_wav = _xfade_audio(master_wav, trimmed["waveform"], sr)
print(
f"[{TAG}] hop {i + 1}: dropped {overlap_n} frames / {dropped} audio samples",
flush=True,
)
del trimmed
if want_sheet:
# The frames this hop actually CONTRIBUTES: a chain start
# (hop 1 or a restart) gives all of them, a continuation
# gives what survives the overlap trim. Showing imgs[0] on
# a continuation would show a frame the master never contains.
_f0 = imgs[0] if hop_is_start else imgs[overlap_n]
_row_seed = (int(shot["seed"]) if shot.get("seed") is not None
else ((int(seed) + i) if seed_per_shot else int(seed)))
sheet_rows.append({
"hop": i + 1,
"first": _sheet.small(_f0),
"last": _sheet.small(imgs[-1]),
"beat": (shot.get("beat") or "").strip() or "(continues)",
"directives": dict(shot.get("directives") or {}),
"note": ("tone: " + tone_note) if tone_note else None,
"meta": [
f"{int(imgs.shape[0])}f",
f"seed {_row_seed}",
f"{int(shot.get('steps') or steps)} steps",
"cached" if cached is not None else None,
f"pin {pin_mech_used}" if i > 0 else None,
f"tone={shot.get('tone')}" if shot.get("tone") else None,
],
})
# The join, as two pictures: the previous hop's last delivered frame
# and this hop's first. Sent together so the panel can show the
# actual seam rather than one frame per hop.
seam_frame = imgs[0] if i > 0 else None
# Overlap tail for Qwen; full sampler latent for Motion-Context.
tail_n = overlap_n if overlap_n else 1
prev_imgs = imgs[-tail_n:].clone()
prev_audio = {"waveform": _tail_audio(audio, overlap_n)["waveform"].clone(),
"sample_rate": sr}
prev_sampled = this_sampled
prev_key = hop_key
_push_preview(
unique_id, f"hop {i + 1}/{n} done",
frame=prev_imgs[-1], hop=i + 1, total=n,
pin_mech=(pin_mech_used if i > 0 else None),
frac=(write_pos / float(total_frames) if total_frames else None),
seam_frame=seam_frame,
meta={"cached": cached is not None,
"key": (hop_key[:8] if hop_key else None),
"frames": int(write_pos), "of_frames": int(total_frames),
"seed": int(shot_seed) if cached is None else None,
"steps": int(hop_steps) if cached is None else None,
"tone": tone_note or None})
del imgs, wav, audio
pbar.update(1)
if dry:
_span = (str(lengths[0]) if len(set(lengths)) == 1
else "/".join(str(v) for v in lengths))
head = (f"DRY RUN - {n} hop(s) compiled, nothing rendered. "
f"{_span}f each, overlap {overlap_n}, "
f"would deliver {total_frames} frames "
f"({total_frames / FPS:.1f}s) at {int(width)}x{int(height)}.")
print(f"[{TAG}] {head}", flush=True)
_sep = chr(10) * 2
info = head + _sep + _sep.join(
("===== hop %d prompt =====" + chr(10) + "%s") % (k, t)
for k, t in assembled)
sheet = _sheet.build(
sheet_rows,
title=f"DRY RUN - {n} hop(s), {total_frames} frames "
f"({total_frames / FPS:.1f}s) - nothing rendered")
_push_preview(unique_id, f"dry run - {n} hop(s) compiled",
hop=n, total=n, frac=1.0,
meta={"dry_run": True, "hops": int(n),
"would_be_frames": int(total_frames),
"done": True})
# Sized to the geometry the chain WOULD have produced, not 1x1:
# the images output usually lands in a video encoder, and one black
# frame at the real resolution both encodes cleanly and shows the
# dimensions the plan resolved to. One frame is not the master --
# the 31 GB allocation this mode exists to avoid is untouched.
return (_sheet.placeholder(width, height),
{"waveform": torch.zeros((1, 2, 1024), dtype=torch.float32),
"sample_rate": 44100},
info,
sheet)
if write_pos != total_frames:
print(f"[{TAG}] note: wrote {write_pos} of {total_frames} planned "
f"frames; trimming", flush=True)
master_imgs = master_imgs[:write_pos]
if hop_store is not None:
hop_store.sweep(keep=hop_keys)
# lengths[0], not `length`: when every shot overrides duration to the
# same value the set is still size 1, but `length` is the chain default.
span = str(lengths[0]) if len(set(lengths)) == 1 else "/".join(str(v) for v in lengths)
info = (
f"{n} hops x {span}f overlap {overlap_n} -> "
f"{int(master_imgs.shape[0])} frames ({master_imgs.shape[0] / FPS:.1f}s) "
f"{int(master_imgs.shape[2])}x{int(master_imgs.shape[1])}"
)
# Machine-readable, on its own line, first thing after the header. The
# seam report reads this instead of asking the user to retype `hops`
# and `overlap` and then solving for them -- which is both a chore and,
# since restart hops stopped being trimmed, wrong.
if seam_marks:
info += chr(10) + "seams: " + ", ".join(str(f) for f in seam_marks)
print(f"[{TAG}] {info}", flush=True)
# The assembled prompts being inspectable is the whole point of the
# directive layer -- wire `info` to a Preview Text node to read exactly
# what each hop sent to the text encoder.
_sep = '\n\n'
info = info + _sep + _sep.join('===== hop %d prompt =====\n%s' % (k, t) for k, t in assembled)
# `info` is the third return (Preview Text). Never send it as the
# preview status — that strip is ~22 px and will render the dump.
_v_secs = float(master_imgs.shape[0]) / FPS
_a_secs = float(master_wav.shape[-1]) / float(sr) if sr else 0.0
_push_preview(
unique_id,
f"done · {int(master_imgs.shape[0])}f · {_v_secs:.1f}s",
frame=master_imgs[-1], hop=n, total=n, frac=1.0,
meta={"video_s": round(_v_secs, 3), "audio_s": round(_a_secs, 3),
"drift_ms": round((_a_secs - _v_secs) * 1000.0, 1),
"hops": int(n), "frames": int(master_imgs.shape[0]),
"done": True})
if locked is not None and master_wav is not None:
_n = _alock.passthrough_n_samples(
int(master_imgs.shape[0]), locked["sr"], FPS)
_out = _alock.fit_samples(locked["wav"], _n)
master_wav = _out.unsqueeze(0)
sr = locked["sr"]
_dur = _n / float(sr) if sr else 0.0
print(f"[{TAG}] final audio: passthrough of master_audio_file "
f"[0.00s-{_dur:.2f}s]", flush=True)
# The soundtrack goes on LAST, after every hop is joined and the seams
# are crossfaded. That placement is the whole safety argument: it runs
# once, downstream of every latent, every pin and every cache key, so it
# cannot move a generated frame or sample -- it only decides what is
# laid over them. It also means a cached chain can be re-mixed at a
# different level for the price of the mix alone.
# The socket wins over the picker when both are set. A wire is a
# deliberate act; a filename left in the panel from an earlier take is
# not, and silently preferring the stale one would be the worse guess.
_bed = soundtrack
if _bed is None and soundtrack_file:
_bed = _media.load_audio(soundtrack_file,
start=float(music_start_s),
end=float(music_end_s))
elif _bed is not None and soundtrack_file:
print(f"[{TAG}] soundtrack: using the wired socket, not "
f"{soundtrack_file!r}", flush=True)
if _bed is not None and master_wav is not None:
try:
master_wav, _mnote = _music.apply(
master_wav, sr,
_bed.get("waveform"), _bed.get("sample_rate", sr),
gain_db=float(music_gain_db), duck=float(music_duck),
fit_mode=str(music_fit), fade_s=float(music_fade_s))
if _mnote:
print(f"[{TAG}] {_mnote}", flush=True)
info = info + "\n" + _mnote
except Exception as _me: # noqa: BLE001
# A bad music file must not destroy a render that has already
# cost minutes of GPU. Report it and hand back the audio the
# chain actually generated.
print(f"[{TAG}] soundtrack skipped ({_me!r})", flush=True)
info = info + f"\nsoundtrack skipped: {_me}"
master_audio = {"waveform": master_wav, "sample_rate": sr}
sheet = _sheet.placeholder()
if want_sheet:
sheet = _sheet.build(
sheet_rows,
title=(f"Hand Tie Clips - {n} hop(s), "
f"{int(master_imgs.shape[0])} frames ({_v_secs:.1f}s) "
f"@ {int(master_imgs.shape[2])}x{int(master_imgs.shape[1])}"
+ (" - DRAFT" if draft else "")))
print(f"[{TAG}] contact sheet: {int(sheet.shape[2])}x"
f"{int(sheet.shape[1])}", flush=True)
return (master_imgs, master_audio, info, sheet)
class HTCContinuityState:
"""Author locked/context/mutable *setting* text once; HandTieClips consumes it per hop.
locked and context ride every hop 2+ unchanged. mutable is --- delimited like the
prompt field: one beat per hop, padded by repeating the last block if there are
fewer blocks than hops.
Setting only. Characters live in `ref_plan`'s reference register, which is
the only thing that knows a photograph is a face rather than a room. This
node used to carry `characters_*` as well, so filling in both it and the
register injected identity prose twice into every hop 2+ -- run() warned
about that collision rather than preventing it. With the character half
gone the collision is structurally impossible.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"setting_locked": ("STRING", {
"multiline": True, "default": "",
"tooltip": "Verbatim setting text (location, lighting). Injected unchanged into every hop 2+.",
}),
"setting_context": ("STRING", {
"multiline": True, "default": "",
"tooltip": "Current-state setting text, less rigid than locked. Injected every hop 2+.",
}),
"setting_mutable": ("STRING", {
"multiline": True, "default": "",
"tooltip": "Per-hop setting beat text, --- delimited like characters_mutable.",
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("continuity_state",)
FUNCTION = "run"
CATEGORY = "Hand Tie Clips"
DESCRIPTION = (
"Builds a JSON continuity-state blob (locked/context/mutable) for the "
"*setting* only, feeding HandTieClips's continuity_state input. "
"hop_script=next only. Characters belong in ref_plan's register."
)
def run(self, setting_locked="", setting_context="", setting_mutable=""):
state = {
"setting": {
"locked": setting_locked.strip(),
"context": setting_context.strip(),
"mutable": _parse_shots(setting_mutable) if setting_mutable.strip() else [],
},
}
return (json.dumps(state),)
# -- pre-rename ids ----------------------------------------------------------
# A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
# lists the node a second time in search: ComfyUI falls back to the mapping key
# when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
# DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
# frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
# while leaving it fully functional in workflows that name it.
class _LegacyH3RefChain(HandTieClips):
DEPRECATED = True
class _LegacyH3ContinuityState(HTCContinuityState):
DEPRECATED = True
NODE_CLASS_MAPPINGS = {
"HandTieClips": HandTieClips,
"HTCContinuityState": HTCContinuityState,
"H3RefChain": _LegacyH3RefChain,
"H3ContinuityState": _LegacyH3ContinuityState,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"HandTieClips": "H3 Ref2VA Chain",
"HTCContinuityState": "H3 Continuity State",
}
|