Spaces:
Sleeping
Sleeping
File size: 192,036 Bytes
a79154d | 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 | import os
import json
import base64
import asyncio
import traceback
import datetime
import threading
import re
from typing import Dict, List, Any, Optional, Tuple
from io import BytesIO
from fastapi import FastAPI, Body, HTTPException, BackgroundTasks, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, Response
from urllib.parse import unquote, quote
import httpx
from openai import OpenAI
from PIL import Image
# HF OAuth (optional - graceful fallback if not available in env)
try:
from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
_HF_OAUTH_AVAILABLE = True
except Exception as _hf_oauth_import_err:
print(f"[OAUTH] huggingface_hub OAuth helpers not available: {_hf_oauth_import_err}")
_HF_OAUTH_AVAILABLE = False
attach_huggingface_oauth = None
parse_huggingface_oauth = None
# ── Environment ──────────────────────────────────────────────
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
# gpt-image-2 (2026-04-21 release). Override via env if needed (e.g. fallback to gpt-image-1.5).
OPENAI_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "gpt-image-2")
# gpt-image-2 prompt cap is large but we keep a safety margin to avoid 400 errors.
OPENAI_PROMPT_MAX_CHARS = int(os.environ.get("OPENAI_PROMPT_MAX_CHARS", "32000"))
# Resize any input image down to this long-side before sending to the API.
# 2304 matches the largest portrait output (1536x2304 for 4K), so generated
# images sent back through /api/edit do NOT get downscaled — preventing the
# generational quality loss that accumulates when an image is repeatedly
# edited (resize → re-encode → API regenerate → resize again …).
# Quality 95 with 4:4:4 chroma (set in _shrink_image_bytes) keeps re-encode
# loss visually negligible (~45 dB PSNR) at ~3× the bytes of the previous Q90.
OPENAI_INPUT_MAX_SIDE = int(os.environ.get("OPENAI_INPUT_MAX_SIDE", "2304"))
OPENAI_INPUT_JPEG_QUALITY = int(os.environ.get("OPENAI_INPUT_JPEG_QUALITY", "95"))
# Tone-reference: a fixed photo whose color distribution should guide the
# generated model's skin tone. We do NOT pass the original photo (its face,
# outfit, and pose would leak into outputs); instead we mosaic it down to a
# tiny grid (TONE_REF_MOSAIC_GRID cells per axis) so all recognizable form
# is destroyed and only the color palette survives. Applied only on first
# generation (MODE B); skipped for transforms and edits where the existing
# image already dictates the tone.
TONE_REF_ENABLED = os.environ.get("TONE_REF_ENABLED", "1") == "1"
TONE_REF_FILE = os.environ.get("TONE_REF_FILE", "tone_reference.png")
TONE_REF_MOSAIC_W = int(os.environ.get("TONE_REF_MOSAIC_W", "6"))
TONE_REF_MOSAIC_H = int(os.environ.get("TONE_REF_MOSAIC_H", "9"))
HF_TOKEN = os.environ.get("HF_TOKEN", "")
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "")
# ── Shared OpenAI client (reuses httpx connection pool) ──────
# gpt-image-2 can take 30–120s for high quality. A read timeout of 180s is safe;
# connect is short because we fail fast on DNS/network issues.
_OPENAI_HTTP_TIMEOUT = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
_openai_client_instance: Optional[OpenAI] = None
def get_openai_client() -> OpenAI:
"""Module-level singleton. Reusing the client avoids re-creating the
underlying httpx pool on every request (~200–500ms/request savings).
"""
global _openai_client_instance
if _openai_client_instance is None:
if not OPENAI_API_KEY:
raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
_openai_client_instance = OpenAI(
api_key=OPENAI_API_KEY,
timeout=_OPENAI_HTTP_TIMEOUT,
# SDK-level retries for transient 5xx/429; our own retry handles size errors.
max_retries=2,
)
print(f"[OPENAI] Initialized shared client (read_timeout={_OPENAI_HTTP_TIMEOUT.read}s)")
return _openai_client_instance
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# ── HF OAuth ─────────────────────────────────────────────────
# When deployed on a Space with `hf_oauth: true` in README.md, this attaches
# /oauth/huggingface/login and /oauth/huggingface/logout endpoints and enables
# session-based user identification via `parse_huggingface_oauth(request)`.
_OAUTH_ATTACHED = False
if _HF_OAUTH_AVAILABLE:
try:
attach_huggingface_oauth(app)
_OAUTH_ATTACHED = True
print("[OAUTH] HF OAuth endpoints attached successfully")
except Exception as _attach_err:
print(f"[OAUTH] attach_huggingface_oauth failed (likely running outside Space): {_attach_err}")
def get_current_user(request: Request) -> dict:
"""Extract the logged-in user from the HF OAuth session.
Returns a dict with keys: username, name, email, is_logged_in.
Falls back to anonymous if OAuth isn't set up or user isn't logged in.
"""
default = {"username": "anonymous", "name": "", "email": "", "is_logged_in": False}
if not _OAUTH_ATTACHED or parse_huggingface_oauth is None:
return default
try:
oauth_info = parse_huggingface_oauth(request)
if oauth_info is None or not getattr(oauth_info, "user_info", None):
return default
ui = oauth_info.user_info
return {
"username": getattr(ui, "preferred_username", "") or "anonymous",
"name": getattr(ui, "name", "") or "",
"email": getattr(ui, "email", "") or "",
"is_logged_in": True,
}
except Exception as parse_err:
print(f"[OAUTH] parse_huggingface_oauth error: {parse_err}")
return default
# REQUIRE_LOGIN=true (기본값) 환경에서 OAuth가 붙어 있으면 로그인된 사용자만 API 호출 가능.
# 로컬 개발(OAuth 미연결)에서는 자동으로 anonymous 허용.
REQUIRE_LOGIN = os.environ.get("REQUIRE_LOGIN", "true").lower() in ("1", "true", "yes", "on")
def require_login(current_user: dict) -> None:
"""Raise 401 if OAuth is on but the user isn't logged in."""
if REQUIRE_LOGIN and _OAUTH_ATTACHED and not current_user["is_logged_in"]:
raise HTTPException(
status_code=401,
detail="로그인이 필요합니다. 우측 상단의 'Hugging Face로 로그인' 버튼을 눌러 주세요.",
)
# ── Static files ─────────────────────────────────────────────
if os.path.exists("assets"):
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
_LOGIN_GATE_HTML = """<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/><title>CLOVIS AI 룩북 생성기 · 로그인</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>*{font-family:'Pretendard',-apple-system,sans-serif;box-sizing:border-box}body{margin:0;background:#1a1a1a;color:#f9fafb;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{width:min(420px,92vw);background:#1e1e1e;border:1px solid #2a2a2a;border-radius:20px;padding:36px 32px;text-align:center}
.logo{width:44px;height:44px;border-radius:12px;background:#3B82F6;display:inline-flex;align-items:center;justify-content:center;margin-bottom:18px}
h1{font-size:18px;margin:0 0 6px}p{font-size:13px;color:#9ca3af;line-height:1.6;margin:0 0 24px}
a.btn{display:block;background:#3B82F6;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:13px;border-radius:12px}a.btn:hover{background:#2563EB}
.note{font-size:11.5px;color:#6b7280;margin-top:16px}</style></head><body><div class="card">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg></div>
<h1>CLOVIS AI 룩북 생성기</h1><p>이랜드이노플 조직 멤버만 사용할 수 있습니다.<br/>Hugging Face 계정으로 로그인해 주세요.</p>
<a class="btn" href="/oauth/huggingface/login">🤗 Hugging Face로 로그인</a>
<div class="note">로그인 후 자동으로 돌아옵니다. 조직 멤버가 아니면 접근이 거부됩니다.</div></div></body></html>"""
def _serve_index(request: Request):
"""OAuth가 붙어 있고 로그인이 필요한데 미로그인 상태면 앱 대신 로그인 안내 페이지를 준다."""
if REQUIRE_LOGIN and _OAUTH_ATTACHED and not get_current_user(request)["is_logged_in"]:
return HTMLResponse(_LOGIN_GATE_HTML, status_code=401)
return FileResponse("index.html", media_type="text/html")
@app.get("/")
def index(request: Request):
return _serve_index(request)
@app.get("/api/whoami")
def api_whoami(request: Request):
"""Return the logged-in user info for the frontend to display."""
user = get_current_user(request)
return {
**user,
"oauth_enabled": _OAUTH_ATTACHED,
"login_url": "/oauth/huggingface/login" if _OAUTH_ATTACHED else None,
"logout_url": "/oauth/huggingface/logout" if _OAUTH_ATTACHED else None,
}
@app.get("/api/health")
def health_check():
"""Diagnostic endpoint for HF Spaces deployment."""
return {
"status": "ok",
"model": OPENAI_IMAGE_MODEL,
"openai_key_configured": bool(OPENAI_API_KEY),
"hf_dataset_configured": bool(HF_TOKEN and HF_DATASET_REPO),
"oauth_attached": _OAUTH_ATTACHED,
"face_presets_loaded": list(_FACE_PRESET_CACHE.keys()) if '_FACE_PRESET_CACHE' in globals() else [],
}
# ══════════════════════════════════════════════════════════════
# 사용자별 사용 통계 대시보드
# - HF Dataset의 logs/**/*.json 을 읽어 사용자별로 집계
# - /logs/stats : HTML 대시보드 (사람이 보는 화면)
# - /api/logs/stats : 집계 JSON (프로그램/디버그용)
# 쿼리: ?from=YYYY-MM-DD&to=YYYY-MM-DD (기간 필터), ?refresh=1 (캐시 강제 갱신)
# ══════════════════════════════════════════════════════════════
import time as _time
_STATS_CACHE: Dict[str, Any] = {"records": None, "loaded_at": 0.0}
_STATS_TTL_SEC = 60 # 동일 로그를 1분에 한 번만 재다운로드
def _load_log_records(force: bool = False) -> List[dict]:
"""HF Dataset의 logs/**/*.json 을 모두 내려받아 파싱. 60초 인메모리 캐시.
snapshot_download 는 변경된 파일만 받아오므로(etag 캐시) 반복 호출이 저렴하다.
"""
now = _time.time()
cached = _STATS_CACHE.get("records")
if (not force) and cached is not None and (now - _STATS_CACHE["loaded_at"] < _STATS_TTL_SEC):
return cached
records: List[dict] = []
if not HF_TOKEN or not HF_DATASET_REPO:
_STATS_CACHE.update(records=records, loaded_at=now)
return records
try:
from huggingface_hub import snapshot_download
import glob as _glob
local_dir = snapshot_download(
repo_id=HF_DATASET_REPO,
repo_type="dataset",
allow_patterns=["logs/**/*.json"],
token=HF_TOKEN,
)
for fp in _glob.glob(os.path.join(local_dir, "logs", "**", "*.json"), recursive=True):
try:
with open(fp, encoding="utf-8") as f:
rec = json.load(f)
if isinstance(rec, dict):
records.append(rec)
except Exception:
continue
except Exception as e:
print(f"[STATS] 로그 로드 실패: {e}")
records.sort(key=lambda r: str(r.get("ts") or ""))
_STATS_CACHE.update(records=records, loaded_at=now)
return records
def _fmt_ts(ts: str) -> str:
"""YYYYMMDD_HHMMSS_mmm -> 'YYYY-MM-DD HH:MM'"""
if not ts or len(ts) < 13:
return ts or ""
try:
return f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}"
except Exception:
return ts
def _fmt_day(day: str) -> str:
return f"{day[0:4]}-{day[4:6]}-{day[6:8]}" if len(day) == 8 else day
def _rec_day(rec: dict) -> str:
ts = str(rec.get("ts") or "")
if len(ts) >= 8 and ts[:8].isdigit():
return ts[:8]
tsmp = str(rec.get("timestamp") or "")
return tsmp[:10].replace("-", "") if tsmp else ""
def _user_key(rec: dict) -> str:
return (rec.get("user_username") or rec.get("user_email") or "anonymous").strip() or "anonymous"
_STYLE_CODE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{4,24}$")
_STYLE_CODE_SKIP = frozenset({
"product", "sub", "sub_product", "persona", "preset", "fashion", "thumb", "image",
})
def _extract_style_code(filename: Optional[str]) -> Optional[str]:
"""Extract fashion style code from an original upload filename.
Examples:
CGMW0197M_00.jpg → CGMW0197M
SPFZF4TC20.png → SPFZF4TC20
SPFZF4TC20_front → SPFZF4TC20
"""
if not filename or not isinstance(filename, str):
return None
base = os.path.basename(filename.replace("\\", "/")).strip()
if not base:
return None
stem = os.path.splitext(base)[0]
# Prefer the first underscore-separated token (common: CODE_00 / CODE_front).
token = stem.split("_")[0].strip()
if not token or token.lower() in _STYLE_CODE_SKIP:
return None
if token.lower().startswith(("product", "preset", "persona", "sub")):
return None
if not _STYLE_CODE_RE.match(token):
return None
return token.upper()
def _rec_product_filenames(rec: dict) -> List[str]:
"""Collect original product filenames from a log record (best available fields)."""
names: List[str] = []
for key in ("product_image_filenames", "product_input_filenames", "sub_product_image_filenames", "sub_product_input_filenames"):
vals = rec.get(key) or []
if not isinstance(vals, list):
continue
for v in vals:
if isinstance(v, str) and v.strip():
names.append(v.strip())
# Also accept basenames from stored paths.
for key in ("product_input_paths", "sub_product_input_paths"):
vals = rec.get(key) or []
if not isinstance(vals, list):
continue
for v in vals:
if isinstance(v, str) and v.strip():
names.append(os.path.basename(v.strip()))
# Deduplicate preserving order.
seen = set()
out = []
for n in names:
k = n.lower()
if k in seen:
continue
seen.add(k)
out.append(n)
return out
def _build_stats_payload(date_from: str = "", date_to: str = "") -> dict:
"""logs/*.json 레코드를 사용자별로 집계해 JSON 직렬화 가능한 dict로 반환."""
records = _load_log_records()
df = (date_from or "").replace("-", "")
dt = (date_to or "").replace("-", "")
users: Dict[str, dict] = {}
daily: Dict[str, dict] = {}
total_gen = total_edit = 0
all_days = set()
style_codes: Dict[str, dict] = {}
recent_inputs: List[dict] = []
dist = {"shot": {}, "angle": {}, "gaze": {}, "age": {}, "face": {}}
for rec in records:
day = _rec_day(rec)
if not day:
continue
if df and day < df:
continue
if dt and day > dt:
continue
is_edit = (rec.get("type") == "edited")
uk = _user_key(rec)
u = users.get(uk)
if u is None:
u = users[uk] = {
"username": uk, "name": "", "email": "", "logged_in": False,
"gen": 0, "edit": 0, "ref": 0, "bg": 0,
"angles": {}, "gazes": {},
"shots": {}, "elapsed": [],
"first": "", "last": "", "days": set(),
}
if rec.get("user_name"):
u["name"] = rec["user_name"]
if rec.get("user_email"):
u["email"] = rec["user_email"]
if rec.get("user_logged_in"):
u["logged_in"] = True
ts = str(rec.get("ts") or "")
if ts:
if not u["first"] or ts < u["first"]:
u["first"] = ts
if not u["last"] or ts > u["last"]:
u["last"] = ts
u["days"].add(day)
all_days.add(day)
d = daily.get(day) or daily.setdefault(day, {"day": day, "gen": 0, "edit": 0})
if is_edit:
u["edit"] += 1
d["edit"] += 1
total_edit += 1
else:
u["gen"] += 1
d["gen"] += 1
total_gen += 1
shot = rec.get("shot_label") or "(미지정)"
u["shots"][shot] = u["shots"].get(shot, 0) + 1
if rec.get("has_reference"):
u["ref"] += 1
if rec.get("has_background"):
u["bg"] += 1
opts = rec.get("options") if isinstance(rec.get("options"), dict) else {}
angle = rec.get("camera_angle") or opts.get("angle") or ""
gaze = rec.get("gaze") or opts.get("gaze") or ""
age = rec.get("age_group") or opts.get("age") or ""
face_id = rec.get("model_face") or ""
if angle:
u["angles"][angle] = u["angles"].get(angle, 0) + 1
dist["angle"][angle] = dist["angle"].get(angle, 0) + 1
if gaze:
u["gazes"][gaze] = u["gazes"].get(gaze, 0) + 1
dist["gaze"][gaze] = dist["gaze"].get(gaze, 0) + 1
if age:
dist["age"][age] = dist["age"].get(age, 0) + 1
if face_id:
dist["face"][face_id] = dist["face"].get(face_id, 0) + 1
dist["shot"][shot] = dist["shot"].get(shot, 0) + 1
es = rec.get("elapsed_sec")
if isinstance(es, (int, float)):
u["elapsed"].append(float(es))
# Style-code / original filename aggregation (generation only).
filenames = _rec_product_filenames(rec)
codes_in_rec = []
for fn in filenames:
code = _extract_style_code(fn)
if not code:
continue
codes_in_rec.append(code)
sc = style_codes.get(code)
if sc is None:
sc = style_codes[code] = {
"style_code": code,
"gen": 0,
"filenames": set(),
"users": set(),
"last": "",
}
sc["filenames"].add(fn)
sc["users"].add(uk)
if ts and (not sc["last"] or ts > sc["last"]):
sc["last"] = ts
for code in set(codes_in_rec):
style_codes[code]["gen"] += 1
if filenames:
recent_inputs.append({
"ts": ts,
"time": _fmt_ts(ts),
"username": uk,
"name": rec.get("user_name") or uk,
"shot": shot,
"filenames": filenames,
"style_codes": sorted(set(codes_in_rec)),
})
user_list = []
for u in users.values():
total = u["gen"] + u["edit"]
top_shot = max(u["shots"].items(), key=lambda kv: kv[1])[0] if u["shots"] else ""
top_angle = max(u["angles"].items(), key=lambda kv: kv[1])[0] if u["angles"] else ""
gaze_total = sum(u["gazes"].values())
natural_rate = round(u["gazes"].get("자연스러운 시선", 0) / gaze_total * 100) if gaze_total else None
avg_elapsed = round(sum(u["elapsed"]) / len(u["elapsed"]), 1) if u["elapsed"] else None
ref_rate = round(u["ref"] / u["gen"] * 100) if u["gen"] else 0
user_list.append({
"username": u["username"], "name": u["name"], "email": u["email"],
"logged_in": u["logged_in"],
"gen": u["gen"], "edit": u["edit"], "total": total,
"active_days": len(u["days"]),
"top_shot": top_shot, "shots": u["shots"],
"top_angle": top_angle, "angles": u["angles"],
"gazes": u["gazes"], "natural_gaze_rate": natural_rate,
"ref_rate": ref_rate, "avg_elapsed": avg_elapsed,
"first": _fmt_ts(u["first"]), "last": _fmt_ts(u["last"]),
"last_raw": u["last"],
})
user_list.sort(key=lambda x: x["total"], reverse=True)
daily_list = [daily[d] for d in sorted(daily.keys())]
style_list = []
for sc in style_codes.values():
style_list.append({
"style_code": sc["style_code"],
"gen": sc["gen"],
"users": len(sc["users"]),
"filenames": sorted(sc["filenames"])[:8],
"filename_count": len(sc["filenames"]),
"last": _fmt_ts(sc["last"]),
"last_raw": sc["last"],
})
style_list.sort(key=lambda x: (-x["gen"], x["style_code"]))
recent_inputs.sort(key=lambda x: x.get("ts") or "", reverse=True)
recent_inputs = recent_inputs[:80]
return {
"summary": {
"users": len(user_list), "gen": total_gen, "edit": total_edit,
"total": total_gen + total_edit, "days": len(all_days),
"style_codes": len(style_list),
"first_day": _fmt_day(min(all_days)) if all_days else "",
"last_day": _fmt_day(max(all_days)) if all_days else "",
"configured": bool(HF_TOKEN and HF_DATASET_REPO),
"repo": HF_DATASET_REPO or "(미설정)",
"loaded_at": datetime.datetime.fromtimestamp(_STATS_CACHE["loaded_at"]).strftime("%Y-%m-%d %H:%M:%S") if _STATS_CACHE["loaded_at"] else "",
},
"users": user_list,
"daily": daily_list,
"dist": {k: sorted(v.items(), key=lambda kv: -kv[1]) for k, v in dist.items()},
"style_codes": style_list,
"recent_inputs": recent_inputs,
"filter": {"from": date_from, "to": date_to},
}
def _render_stats_html(payload: dict) -> str:
data_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
return _STATS_HTML_TEMPLATE.replace("__DATA__", data_json)
@app.get("/api/logs/stats")
def api_logs_stats(request: Request):
qp = request.query_params
if qp.get("refresh"):
_load_log_records(force=True)
return JSONResponse(_build_stats_payload(qp.get("from", ""), qp.get("to", "")))
@app.get("/logs/stats", response_class=HTMLResponse)
def logs_stats_page(request: Request):
qp = request.query_params
if qp.get("refresh"):
_load_log_records(force=True)
payload = _build_stats_payload(qp.get("from", ""), qp.get("to", ""))
return HTMLResponse(_render_stats_html(payload))
_STATS_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AI 룩북 · 사용 통계</title>
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
:root{
--bg:#f7f8fa; --panel:#ffffff; --border:#e6e8ec; --ink:#1a202c;
--muted:#6b7280; --accent:#2563eb; --accent-soft:#eff4ff;
--edit:#0d9488; --warn:#d97706; --track:#eef1f5;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:'Pretendard',-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
-webkit-font-smoothing:antialiased;}
a{color:inherit}
.wrap{max-width:1180px;margin:0 auto;padding:28px 20px 60px}
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:22px;font-weight:800;margin:0;letter-spacing:-.02em}
.sub{color:var(--muted);font-size:13px;margin-top:4px}
.sub code{background:#eef1f5;padding:1px 6px;border-radius:5px;font-size:12px}
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.toolbar input[type=date]{font-family:inherit;border:1px solid var(--border);border-radius:8px;
padding:7px 9px;font-size:13px;background:#fff;color:var(--ink)}
.btn{border:1px solid var(--border);background:#fff;border-radius:8px;padding:8px 13px;
font-size:13px;font-weight:600;cursor:pointer;color:var(--ink);text-decoration:none;
display:inline-flex;align-items:center;gap:6px;transition:.15s}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.btn.primary:hover{filter:brightness(1.05);color:#fff}
.cards{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin:22px 0}
@media(max-width:960px){.cards{grid-template-columns:repeat(3,1fr)}}
@media(max-width:560px){.cards{grid-template-columns:repeat(2,1fr)}}
.card{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
.card .k{font-size:12px;color:var(--muted);font-weight:600;letter-spacing:.01em}
.card .v{font-size:26px;font-weight:800;margin-top:6px;letter-spacing:-.02em}
.card .v small{font-size:13px;font-weight:600;color:var(--muted)}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:18px 20px;margin-bottom:18px}
.panel h2{font-size:14px;font-weight:700;margin:0 0 14px;display:flex;align-items:center;gap:8px}
.panel h2 .tag{font-size:11px;font-weight:600;color:var(--muted)}
.fn{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;
background:#f3f5f8;padding:2px 7px;border-radius:6px;display:inline-block;margin:1px 3px 1px 0;
max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}
.codepill{display:inline-block;font-size:12px;font-weight:700;letter-spacing:.02em;
padding:3px 9px;border-radius:8px;background:#111827;color:#fff}
/* daily chart */
.chart{display:flex;align-items:flex-end;gap:6px;height:160px;overflow-x:auto;padding-bottom:6px}
.dist-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
.dist h3{font-size:12px;color:var(--muted);margin:0 0 8px;font-weight:600}
.dist .row{display:grid;grid-template-columns:96px 1fr 44px;align-items:center;gap:8px;font-size:12px;margin:4px 0}
.dist .row .n{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dist .row .t{height:8px;border-radius:999px;background:var(--track);overflow:hidden}
.dist .row .t i{display:block;height:100%;background:var(--accent);border-radius:999px}
.dist .row .c{text-align:right;color:var(--muted);font-variant-numeric:tabular-nums}
.col{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:26px;flex:0 0 auto}
.bar{width:18px;border-radius:5px 5px 0 0;background:var(--track);position:relative;display:flex;flex-direction:column-reverse;overflow:hidden}
.seg-gen{background:var(--accent)}
.seg-edit{background:var(--edit)}
.col .lbl{font-size:10px;color:var(--muted);white-space:nowrap;transform:rotate(-45deg);transform-origin:center;height:30px;margin-top:4px}
.legend{display:flex;gap:16px;font-size:12px;color:var(--muted);margin-top:6px}
.legend span{display:inline-flex;align-items:center;gap:6px}
.dot{width:10px;height:10px;border-radius:3px;display:inline-block}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{text-align:left;font-weight:600;color:var(--muted);font-size:12px;
padding:10px 10px;border-bottom:1px solid var(--border);white-space:nowrap;cursor:pointer;user-select:none}
thead th.num{text-align:right}
thead th:hover{color:var(--accent)}
thead th .arrow{opacity:.4;font-size:10px;margin-left:3px}
thead th.active .arrow{opacity:1;color:var(--accent)}
tbody td{padding:11px 10px;border-bottom:1px solid #f1f3f6;vertical-align:middle}
tbody tr:hover{background:#fafbfc}
td.num{text-align:right;font-variant-numeric:tabular-nums}
.u-name{font-weight:700}
.u-id{color:var(--muted);font-size:11.5px;margin-top:1px}
a.ulink{text-decoration:none;color:inherit;display:inline-block}
a.ulink:hover .u-name{color:var(--accent);text-decoration:underline}
.u-name .chev{font-size:11px;color:var(--accent);margin-left:5px;opacity:.7}
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
.pill.gray{background:#f1f3f6;color:var(--muted)}
.pill.anon{background:#fdf2f2;color:#b91c1c}
.barmini{height:6px;border-radius:4px;background:var(--track);overflow:hidden;width:60px;display:inline-block;vertical-align:middle;margin-right:6px}
.barmini > i{display:block;height:100%;background:var(--accent)}
.empty{text-align:center;color:var(--muted);padding:40px 0;font-size:14px}
.foot{color:var(--muted);font-size:12px;margin-top:18px;text-align:center}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>AI 룩북 · 사용 통계</h1>
<div class="sub">데이터셋 <code id="repo"></code> · 마지막 갱신 <span id="loaded"></span></div>
</div>
<div class="toolbar">
<input type="date" id="from"/>
<span style="color:var(--muted)">~</span>
<input type="date" id="to"/>
<button class="btn primary" onclick="applyFilter()">기간 적용</button>
<button class="btn" onclick="resetFilter()">전체</button>
<a class="btn" id="refreshBtn" href="?refresh=1">↻ 새로고침</a>
</div>
</header>
<div class="cards" id="cards"></div>
<div class="panel">
<h2>일자별 활동 <span class="tag" id="rangeTag"></span></h2>
<div class="chart" id="chart"></div>
<div class="legend">
<span><i class="dot" style="background:var(--accent)"></i>생성</span>
<span><i class="dot" style="background:var(--edit)"></i>편집</span>
</div>
</div>
<div class="panel">
<h2>샷 · 앵글 · 시선 분포 <span class="tag">생성 기준</span></h2>
<div class="dist-grid" id="dist"></div>
</div>
<div class="panel">
<h2>스타일코드별 생성 <span class="tag">원본 파일명에서 추출 · 열 제목 클릭 정렬</span></h2>
<div style="overflow-x:auto">
<table id="styleTable">
<thead>
<tr>
<th data-k="style_code" data-t="str" data-table="style">스타일코드<span class="arrow"></span></th>
<th class="num active" data-k="gen" data-table="style">생성<span class="arrow">▼</span></th>
<th class="num" data-k="users" data-table="style">사용자<span class="arrow"></span></th>
<th data-k="filenames" data-t="str" data-table="style">원본 파일명 예시<span class="arrow"></span></th>
<th data-k="last_raw" data-t="str" data-table="style">최근 생성<span class="arrow"></span></th>
</tr>
</thead>
<tbody id="styleBody"></tbody>
</table>
</div>
<div class="empty" id="styleEmpty" style="display:none">스타일코드를 추출할 원본 파일명 로그가 없습니다. (배포 이후 생성분부터 집계됩니다)</div>
</div>
<div class="panel">
<h2>최근 생성 · 원본 파일명 <span class="tag">최대 80건</span></h2>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th>시각</th>
<th>사용자</th>
<th>샷</th>
<th>스타일코드</th>
<th>원본 파일명</th>
</tr>
</thead>
<tbody id="recentBody"></tbody>
</table>
</div>
<div class="empty" id="recentEmpty" style="display:none">원본 파일명이 기록된 최근 생성이 없습니다.</div>
</div>
<div class="panel">
<h2>사용자별 사용 현황 <span class="tag">열 제목을 클릭하면 정렬됩니다</span></h2>
<div style="overflow-x:auto">
<table id="userTable">
<thead>
<tr>
<th data-k="name" data-t="str" data-table="user">사용자<span class="arrow"></span></th>
<th class="num" data-k="gen" data-table="user">생성<span class="arrow"></span></th>
<th class="num" data-k="edit" data-table="user">편집<span class="arrow"></span></th>
<th class="num active" data-k="total" data-table="user">합계<span class="arrow">▼</span></th>
<th class="num" data-k="active_days" data-table="user">활동일<span class="arrow"></span></th>
<th data-k="top_shot" data-t="str" data-table="user">주 사용 샷<span class="arrow"></span></th>
<th data-k="top_angle" data-t="str" data-table="user">주 앵글<span class="arrow"></span></th>
<th class="num" data-k="natural_gaze_rate" data-table="user">자연 시선율<span class="arrow"></span></th>
<th class="num" data-k="ref_rate" data-table="user">레퍼런스율<span class="arrow"></span></th>
<th class="num" data-k="avg_elapsed" data-table="user">평균 소요<span class="arrow"></span></th>
<th data-k="last_raw" data-t="str" data-table="user">최근 활동<span class="arrow"></span></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="empty" id="empty" style="display:none">표시할 로그가 없습니다.</div>
</div>
<div class="foot" id="foot"></div>
</div>
<script>
const DATA = __DATA__;
let sortKey = "total", sortType = "num", sortDir = -1;
let styleSortKey = "gen", styleSortType = "num", styleSortDir = -1;
function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));}
function renderCards(){
const s = DATA.summary;
const range = (s.first_day && s.last_day) ? (s.first_day + " ~ " + s.last_day) : "데이터 없음";
document.getElementById("repo").textContent = s.repo;
document.getElementById("loaded").textContent = s.loaded_at || "-";
document.getElementById("rangeTag").textContent = range;
const cards = [
["사용자", s.users, ""],
["총 생성", s.gen, "건"],
["총 편집", s.edit, "건"],
["스타일코드", s.style_codes || 0, "종"],
["활동 일수", s.days, "일"],
["기간", "", range],
];
document.getElementById("cards").innerHTML = cards.map(([k,v,suf])=>{
const val = (k==="기간") ? `<span style="font-size:14px;font-weight:700">${esc(suf)||"-"}</span>`
: `${Number(v).toLocaleString()}${suf?` <small>${suf}</small>`:""}`;
return `<div class="card"><div class="k">${k}</div><div class="v">${val}</div></div>`;
}).join("");
if(!s.configured){
document.getElementById("foot").innerHTML =
"⚠️ HF_TOKEN / HF_DATASET_REPO 환경변수가 설정되지 않아 로그를 불러올 수 없습니다. (로컬 개발 환경)";
}
}
function renderChart(){
const daily = DATA.daily || [];
const el = document.getElementById("chart");
if(!daily.length){ el.innerHTML = '<div class="empty" style="width:100%">활동 기록이 없습니다.</div>'; return; }
const max = Math.max(...daily.map(d=>d.gen+d.edit), 1);
const H = 120;
el.innerHTML = daily.map(d=>{
const tot = d.gen+d.edit;
const h = Math.round(tot/max*H);
const gh = tot ? Math.round(d.gen/tot*h) : 0;
const eh = h - gh;
const day = d.day, lbl = day.slice(4,6)+"/"+day.slice(6,8);
return `<div class="col" title="${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6,8)} · 생성 ${d.gen} / 편집 ${d.edit}">
<div class="bar" style="height:${Math.max(h,3)}px">
<div class="seg-edit" style="height:${eh}px"></div>
<div class="seg-gen" style="height:${gh}px"></div>
</div>
<div class="lbl">${lbl}</div>
</div>`;
}).join("");
}
function renderDist(){
const d = DATA.dist || {};
const el = document.getElementById("dist");
const groups = [["샷","shot"],["카메라 앵글","angle"],["시선","gaze"],["연령","age"],["얼굴 프리셋","face"]];
const html = groups.map(([title,key])=>{
const rows = d[key] || [];
if(!rows.length) return "";
const total = rows.reduce((a,[,c])=>a+c,0) || 1;
const max = rows[0][1] || 1;
return `<div class="dist"><h3>${title}</h3>` + rows.map(([name,c])=>
`<div class="row"><span class="n" title="${esc(name)}">${esc(name)}</span><span class="t"><i style="width:${Math.round(c/max*100)}%"></i></span><span class="c">${c} · ${Math.round(c/total*100)}%</span></div>`
).join("") + `</div>`;
}).filter(Boolean).join("");
el.innerHTML = html || '<div class="empty" style="width:100%">앵글·시선 정보가 기록된 생성이 없습니다. (Comfy 브리지 배포 이후 생성분부터 집계됩니다)</div>';
}
function renderTable(){
const rows = [...DATA.users];
rows.sort((a,b)=>{
let x=a[sortKey], y=b[sortKey];
if(sortType==="str"){ x=(x||"").toString().toLowerCase(); y=(y||"").toString().toLowerCase();
return x<y?-1*sortDir:x>y?1*sortDir:0; }
x = (x==null?-1:Number(x)); y = (y==null?-1:Number(y));
return (x-y)*sortDir;
});
const tb = document.getElementById("tbody");
const empty = document.getElementById("empty");
if(!rows.length){ tb.innerHTML=""; empty.style.display="block"; return; }
empty.style.display="none";
const maxTotal = Math.max(...rows.map(r=>r.total),1);
const f=DATA.filter||{}; const qs=[]; if(f.from)qs.push("from="+f.from); if(f.to)qs.push("to="+f.to);
const dateQS = qs.length?("?"+qs.join("&")):"";
tb.innerHTML = rows.map(u=>{
const isAnon = u.username==="anonymous";
const href = "/logs/user/"+encodeURIComponent(u.username)+dateQS;
const inner = `<div class="u-name">${esc(u.name || u.username)}<span class="chev">›</span></div>`
+ (isAnon ? `<span class="pill anon">비로그인</span>`
: `<div class="u-id">@${esc(u.username)}${u.email?` · ${esc(u.email)}`:""}</div>`);
const nameCell = `<a class="ulink" href="${href}">${inner}</a>`;
const totalBar = `<span class="barmini"><i style="width:${Math.round(u.total/maxTotal*100)}%"></i></span>`;
const shot = u.top_shot ? `<span class="pill gray">${esc(u.top_shot)}</span>` : "-";
const angle = u.top_angle ? `<span class="pill gray">${esc(u.top_angle)}</span>` : "-";
const gazeTip = Object.entries(u.gazes||{}).map(([k,v])=>`${k} ${v}`).join(" / ");
const gaze = (u.natural_gaze_rate==null) ? "-" : `<span title="${esc(gazeTip)}">${u.natural_gaze_rate}%</span>`;
const avg = (u.avg_elapsed==null) ? "-" : `${u.avg_elapsed}s`;
return `<tr>
<td>${nameCell}</td>
<td class="num">${u.gen.toLocaleString()}</td>
<td class="num">${u.edit.toLocaleString()}</td>
<td class="num">${totalBar}<b>${u.total.toLocaleString()}</b></td>
<td class="num">${u.active_days}</td>
<td>${shot}</td>
<td>${angle}</td>
<td class="num">${gaze}</td>
<td class="num">${u.ref_rate}%</td>
<td class="num">${avg}</td>
<td>${esc(u.last)}</td>
</tr>`;
}).join("");
}
function renderStyleTable(){
const rows = [...(DATA.style_codes||[])];
rows.sort((a,b)=>{
let x=a[styleSortKey], y=b[styleSortKey];
if(styleSortKey==="filenames"){
x=(a.filenames&&a.filenames[0]||"").toLowerCase();
y=(b.filenames&&b.filenames[0]||"").toLowerCase();
return x<y?-1*styleSortDir:x>y?1*styleSortDir:0;
}
if(styleSortType==="str"){ x=(x||"").toString().toLowerCase(); y=(y||"").toString().toLowerCase();
return x<y?-1*styleSortDir:x>y?1*styleSortDir:0; }
x = (x==null?-1:Number(x)); y = (y==null?-1:Number(y));
return (x-y)*styleSortDir;
});
const tb = document.getElementById("styleBody");
const empty = document.getElementById("styleEmpty");
if(!rows.length){ tb.innerHTML=""; empty.style.display="block"; return; }
empty.style.display="none";
const maxGen = Math.max(...rows.map(r=>r.gen),1);
tb.innerHTML = rows.map(r=>{
const bar = `<span class="barmini"><i style="width:${Math.round(r.gen/maxGen*100)}%"></i></span>`;
const files = (r.filenames||[]).map(f=>`<span class="fn" title="${esc(f)}">${esc(f)}</span>`).join("");
const more = (r.filename_count||0) > (r.filenames||[]).length
? `<span class="pill gray">+${r.filename_count - r.filenames.length}</span>` : "";
return `<tr>
<td><span class="codepill">${esc(r.style_code)}</span></td>
<td class="num">${bar}<b>${r.gen.toLocaleString()}</b></td>
<td class="num">${r.users}</td>
<td>${files||"-"}${more}</td>
<td>${esc(r.last)||"-"}</td>
</tr>`;
}).join("");
}
function renderRecentInputs(){
const rows = DATA.recent_inputs || [];
const tb = document.getElementById("recentBody");
const empty = document.getElementById("recentEmpty");
if(!rows.length){ tb.innerHTML=""; empty.style.display="block"; return; }
empty.style.display="none";
tb.innerHTML = rows.map(r=>{
const codes = (r.style_codes||[]).map(c=>`<span class="codepill">${esc(c)}</span>`).join(" ") || "-";
const files = (r.filenames||[]).map(f=>`<span class="fn" title="${esc(f)}">${esc(f)}</span>`).join("") || "-";
return `<tr>
<td>${esc(r.time)||"-"}</td>
<td><div class="u-name">${esc(r.name||r.username)}</div><div class="u-id">@${esc(r.username)}</div></td>
<td>${r.shot?`<span class="pill gray">${esc(r.shot)}</span>`:"-"}</td>
<td>${codes}</td>
<td>${files}</td>
</tr>`;
}).join("");
}
document.querySelectorAll("thead th[data-k]").forEach(th=>{
th.addEventListener("click",()=>{
const table = th.dataset.table || "user";
const k = th.dataset.k, t = th.dataset.t==="str"?"str":"num";
if(table==="style"){
if(styleSortKey===k){ styleSortDir *= -1; } else { styleSortKey=k; styleSortType=t; styleSortDir = (t==="str")?1:-1; }
document.querySelectorAll("#styleTable thead th").forEach(x=>{x.classList.remove("active"); const a=x.querySelector(".arrow"); if(a)a.textContent="";});
th.classList.add("active");
th.querySelector(".arrow").textContent = styleSortDir<0?"▼":"▲";
renderStyleTable();
return;
}
if(sortKey===k){ sortDir *= -1; } else { sortKey=k; sortType=t; sortDir = (t==="str")?1:-1; }
document.querySelectorAll("#userTable thead th").forEach(x=>{x.classList.remove("active"); const a=x.querySelector(".arrow"); if(a)a.textContent="";});
th.classList.add("active");
th.querySelector(".arrow").textContent = sortDir<0?"▼":"▲";
renderTable();
});
});
function applyFilter(){
const f=document.getElementById("from").value, t=document.getElementById("to").value;
const p=new URLSearchParams();
if(f)p.set("from",f); if(t)p.set("to",t);
location.search = p.toString();
}
function resetFilter(){ location.search=""; }
(function init(){
if(DATA.filter){
if(DATA.filter.from) document.getElementById("from").value=DATA.filter.from;
if(DATA.filter.to) document.getElementById("to").value=DATA.filter.to;
}
const q=new URLSearchParams(location.search);
const keep=[]; if(q.get("from"))keep.push("from="+q.get("from")); if(q.get("to"))keep.push("to="+q.get("to"));
document.getElementById("refreshBtn").href = "?refresh=1"+(keep.length?"&"+keep.join("&"):"");
renderCards(); renderChart(); renderDist(); renderStyleTable(); renderRecentInputs(); renderTable();
})();
</script>
</body>
</html>"""
# ══════════════════════════════════════════════════════════════
# 사용자별 상세 갤러리
# - 각 생성/편집의 입력·출력 이미지, 프롬프트, 소요시간을 보여줌
# - /logs/user/{username} : HTML 갤러리
# - /api/logs/user/{username} : 상세 JSON
# - /logs/asset/{path} : private dataset 이미지 프록시(토큰으로 받아 스트리밍)
# ══════════════════════════════════════════════════════════════
import re as _re
_DSINDEX_CACHE: Dict[str, Any] = {"index": None, "loaded_at": 0.0}
_TS_RE = _re.compile(r"(\d{8}_\d{6}_\d{3})")
_ASSET_PREFIXES = ("images/", "thumbs/", "inputs/product/", "inputs/persona/")
def _load_dataset_index(force: bool = False) -> Dict[str, dict]:
"""dataset의 전체 파일 목록을 한 번 받아 ts -> {output, thumb, persona, products} 로 매핑."""
now = _time.time()
cached = _DSINDEX_CACHE.get("index")
if (not force) and cached is not None and (now - _DSINDEX_CACHE["loaded_at"] < _STATS_TTL_SEC):
return cached
index: Dict[str, dict] = {}
if not HF_TOKEN or not HF_DATASET_REPO:
_DSINDEX_CACHE.update(index=index, loaded_at=now)
return index
try:
from huggingface_hub import HfApi
files = HfApi(token=HF_TOKEN).list_repo_files(repo_id=HF_DATASET_REPO, repo_type="dataset")
except Exception as e:
print(f"[STATS] list_repo_files 실패: {e}")
files = []
for fp in files:
m = _TS_RE.search(fp)
if not m:
continue
ts = m.group(1)
slot = index.setdefault(ts, {"output": None, "thumb": None, "persona": None, "products": []})
if fp.startswith("images/"):
slot["output"] = fp
elif fp.startswith("thumbs/"):
slot["thumb"] = fp
elif fp.startswith("inputs/persona/"):
slot["persona"] = fp
elif fp.startswith("inputs/product/"):
slot["products"].append(fp)
for slot in index.values():
slot["products"].sort()
_DSINDEX_CACHE.update(index=index, loaded_at=now)
return index
_SLOT_KO = {"outer": "아우터", "top": "상의", "bottom": "하의", "shoes": "신발", "inner": "이너웨어",
"belt": "벨트", "background": "배경", "pose": "포즈"}
_DETAIL_KIND_KO = {"texture": "조직감", "material": "소재", "logo": "로고", "neckline": "넥라인", "collar": "카라",
"sleeve": "소매", "hem": "밑단", "waist": "허리", "pocket": "포켓", "closure": "지퍼·버튼",
"trim": "부자재", "sole": "밑창", "lacing": "끈", "heel": "뒤축", "buckle": "버클", "edge": "스티치"}
def _comfy_input_labels(rec: dict, paths: list) -> tuple:
"""Comfy 기록의 입력 이미지 라벨/순서 복원.
저장 시 순서: product_image_filenames(메인 의상 + 디테일 참조) → sub/(배경·포즈).
파일명은 push 시 _unique_filenames 로 정리되므로 같은 함수를 적용해 basename 으로 매칭한다."""
sf = rec.get("slot_filenames") or {}
ref_keys = [k for k in sf if k in ("background", "pose")]
main_keys = [k for k in sf if k not in ("background", "pose")]
drefs = [d for d in (rec.get("detail_refs") or []) if isinstance(d, dict)]
pnames = [str(x) for x in (rec.get("product_image_filenames") or [])]
main_labels = [_SLOT_KO.get(k, k) for k in main_keys] + \
[f"{_SLOT_KO.get(d.get('garment'), d.get('garment'))}·{_DETAIL_KIND_KO.get(d.get('kind'), d.get('kind'))}" for d in drefs]
if len(pnames) != len(main_labels): # 형식이 안 맞으면 예전 방식으로
return [_SLOT_KO.get(k, k) for k in sf.keys()], paths
expected = [(False, n, lb) for n, lb in zip(_unique_filenames(pnames, "product"), main_labels)] + \
[(True, n, _SLOT_KO.get(k, k)) for n, k in zip(_unique_filenames([str(sf[k]) for k in ref_keys], "sub_product"), ref_keys)]
remaining = list(paths)
ordered, labels = [], []
for is_sub, name, label in expected:
hit = next((p for p in remaining if ("/sub/" in p) == is_sub and os.path.basename(p) == name), None)
if hit is None:
continue
remaining.remove(hit)
ordered.append(hit)
labels.append(label)
for p in remaining: # 매칭 안 된 파일은 뒤에 그대로
ordered.append(p)
labels.append("제품")
return labels, ordered
def _asset_url(path: Optional[str]) -> Optional[str]:
return ("/logs/asset/" + path) if path else None
@app.get("/logs/asset/{path:path}")
def logs_asset(path: str):
"""private dataset 이미지를 토큰으로 받아 그대로 반환(브라우저 <img>가 직접 못 받으니 프록시)."""
decoded = unquote(path)
if not decoded.startswith(_ASSET_PREFIXES) or ".." in decoded:
raise HTTPException(status_code=404, detail="not allowed")
if not HF_TOKEN or not HF_DATASET_REPO:
raise HTTPException(status_code=404, detail="dataset not configured")
url = f"https://huggingface.co/datasets/{HF_DATASET_REPO}/resolve/main/{quote(decoded, safe='/')}"
try:
r = httpx.get(url, headers={"Authorization": f"Bearer {HF_TOKEN}"},
timeout=20.0, follow_redirects=True)
except Exception as e:
raise HTTPException(status_code=502, detail=f"fetch failed: {e}")
if r.status_code != 200:
raise HTTPException(status_code=404, detail="asset not found")
media = r.headers.get("content-type", "application/octet-stream")
return Response(content=r.content, media_type=media,
headers={"Cache-Control": "public, max-age=3600"})
def _build_user_detail(username: str, date_from: str = "", date_to: str = "", limit: int = 200) -> dict:
records = _load_log_records()
index = _load_dataset_index()
df = (date_from or "").replace("-", "")
dt = (date_to or "").replace("-", "")
items = []
name = email = ""
logged_in = False
gen = edit = 0
elapsed_all = []
for rec in records:
if _user_key(rec) != username:
continue
day = _rec_day(rec)
if df and day < df:
continue
if dt and day > dt:
continue
ts = str(rec.get("ts") or "")
assets = index.get(ts, {})
is_edit = (rec.get("type") == "edited")
if rec.get("user_name"):
name = rec["user_name"]
if rec.get("user_email"):
email = rec["user_email"]
if rec.get("user_logged_in"):
logged_in = True
es = rec.get("elapsed_sec")
products = []
for p in (rec.get("product_infos") or []):
if isinstance(p, dict):
products.append(p.get("category") or p.get("name") or "제품")
elif p:
products.append(str(p))
product_paths = list(assets.get("products") or [])
if not products and isinstance(rec.get("slot_filenames"), dict):
# Comfy 브리지 기록: 저장 순서(메인 의상 → 디테일 참조 → sub/배경·포즈)대로 파일을 재정렬하고 라벨을 붙인다.
# 폴더 목록은 알파벳순이라 디테일 참조(원본 파일명)가 앞으로 끼어들 수 있음.
products, product_paths = _comfy_input_labels(rec, product_paths)
items.append({
"ts": ts, "time": _fmt_ts(ts), "day": day,
"kind": "편집" if is_edit else "생성", "is_edit": is_edit,
"shot": rec.get("shot_label") or "",
"mode": rec.get("mode") or "",
"angle": rec.get("camera_angle") or (rec.get("options") or {}).get("angle") or "",
"gaze": rec.get("gaze") or (rec.get("options") or {}).get("gaze") or "",
"model_face": rec.get("model_face") or "",
"elapsed": es if isinstance(es, (int, float)) else None,
"has_reference": bool(rec.get("has_reference")),
"has_background": bool(rec.get("has_background")),
"is_background_change": bool(rec.get("is_background_change")),
"custom_prompt": ((rec.get("edit_instruction") if is_edit else rec.get("custom_prompt")) or ""),
"full_prompt": rec.get("prompt") or "",
"product_cats": products,
"output_url": _asset_url(assets.get("output") or assets.get("thumb")),
"thumb_url": _asset_url(assets.get("thumb") or assets.get("output")),
"persona_url": _asset_url(assets.get("persona")),
"product_urls": [_asset_url(p) for p in product_paths],
})
if is_edit:
edit += 1
else:
gen += 1
if isinstance(es, (int, float)):
elapsed_all.append(float(es))
items.sort(key=lambda x: x["ts"], reverse=True)
truncated = len(items) > limit
items = items[:limit]
avg = round(sum(elapsed_all) / len(elapsed_all), 1) if elapsed_all else None
return {
"user": {"username": username, "name": name, "email": email, "logged_in": logged_in},
"summary": {
"gen": gen, "edit": edit, "total": gen + edit, "avg_elapsed": avg,
"shown": len(items), "truncated": truncated, "limit": limit,
"configured": bool(HF_TOKEN and HF_DATASET_REPO),
},
"items": items,
"filter": {"from": date_from, "to": date_to},
}
def _render_user_html(payload: dict) -> str:
data_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
return _USER_HTML_TEMPLATE.replace("__DATA__", data_json)
@app.get("/api/my/history")
def api_my_history(request: Request):
"""로그인한 본인의 생성 기록 (HF Dataset 영구 저장분). 앱의 '지난 기록' 탭에서 사용."""
user = get_current_user(request)
if _OAUTH_ATTACHED and not user["is_logged_in"]:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if request.query_params.get("refresh"):
_load_log_records(force=True)
_load_dataset_index(force=True)
payload = _build_user_detail(user["username"], limit=60)
items = [{
"ts": it["ts"], "time": it["time"],
"shot": it["shot"], "angle": it["angle"], "gaze": it["gaze"],
"face": it["model_face"], "elapsed": it["elapsed"],
"prompt": it["custom_prompt"], "fullPrompt": it["full_prompt"],
"imageUrl": it["output_url"], "thumbUrl": it["thumb_url"],
} for it in payload["items"] if it.get("output_url")]
return JSONResponse({"items": items, "truncated": payload["summary"]["truncated"],
"configured": payload["summary"]["configured"]})
@app.get("/api/logs/user/{username}")
def api_logs_user(request: Request, username: str):
qp = request.query_params
if qp.get("refresh"):
_load_log_records(force=True)
_load_dataset_index(force=True)
return JSONResponse(_build_user_detail(unquote(username), qp.get("from", ""), qp.get("to", "")))
@app.get("/logs/user/{username}", response_class=HTMLResponse)
def logs_user_page(request: Request, username: str):
qp = request.query_params
if qp.get("refresh"):
_load_log_records(force=True)
_load_dataset_index(force=True)
payload = _build_user_detail(unquote(username), qp.get("from", ""), qp.get("to", ""))
return HTMLResponse(_render_user_html(payload))
_USER_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>사용 상세 · AI 룩북</title>
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
:root{--bg:#f7f8fa;--panel:#fff;--border:#e6e8ec;--ink:#1a202c;--muted:#6b7280;
--accent:#2563eb;--accent-soft:#eff4ff;--edit:#0d9488;--warn:#d97706;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:'Pretendard',-apple-system,BlinkMacSystemFont,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}
.wrap{max-width:1180px;margin:0 auto;padding:24px 20px 60px}
.back{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--muted);text-decoration:none;margin-bottom:14px}
.back:hover{color:var(--accent)}
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
h1{font-size:21px;font-weight:800;margin:0;letter-spacing:-.02em}
h1 .uid{font-size:13px;font-weight:600;color:var(--muted);margin-left:8px}
.meta-sum{display:flex;gap:18px;margin-top:8px;font-size:13px;color:var(--muted);flex-wrap:wrap}
.meta-sum b{color:var(--ink);font-weight:700}
.btn{border:1px solid var(--border);background:#fff;border-radius:8px;padding:7px 12px;font-size:13px;font-weight:600;cursor:pointer;color:var(--ink);text-decoration:none}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}
@media(max-width:820px){.grid{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--border);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
.card-top{display:flex;gap:14px;padding:14px}
.outwrap{flex:0 0 168px;width:168px}
.outwrap a{display:block;border-radius:10px;overflow:hidden;border:1px solid var(--border);background:#f0f2f5;aspect-ratio:3/4}
.outwrap img{width:100%;height:100%;object-fit:cover;display:block}
.card-body{flex:1;min-width:0}
.badges{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.badge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
.badge.edit{background:#e6fffb;color:var(--edit)}
.badge.gray{background:#f1f3f6;color:var(--muted)}
.badge.time{background:#fff7ed;color:var(--warn)}
.when{font-size:12px;color:var(--muted);margin-bottom:10px}
.inputs{margin-top:6px}
.inputs .lbl{font-size:11px;font-weight:700;color:var(--muted);margin-bottom:5px}
.thumbs{display:flex;gap:7px;flex-wrap:wrap}
.thumbs .t{position:relative;width:52px;height:64px;border-radius:7px;overflow:hidden;border:1px solid var(--border);background:#f0f2f5}
.thumbs .t img{width:100%;height:100%;object-fit:cover;display:block}
.thumbs .t .tag{position:absolute;left:0;bottom:0;right:0;font-size:8.5px;text-align:center;background:rgba(0,0,0,.55);color:#fff;padding:1px 0}
.noimg{font-size:12px;color:#aab;padding:6px 0}
.prompt{border-top:1px solid #f1f3f6;padding:12px 14px;background:#fcfcfd}
.prompt .lbl{font-size:11px;font-weight:700;color:var(--muted);margin-bottom:5px;display:flex;justify-content:space-between;align-items:center}
.prompt .box{font-size:12.5px;line-height:1.55;color:#222;white-space:pre-wrap;word-break:break-word;
background:#fff;border:1px solid var(--border);border-radius:9px;padding:9px 11px;max-height:140px;overflow:auto}
.prompt .box.empty{color:#aab;font-style:italic}
.togglefull{font-size:11px;font-weight:600;color:var(--accent);cursor:pointer;user-select:none}
.fullbox{display:none;margin-top:8px}
.fullbox.open{display:block}
.empty{text-align:center;color:var(--muted);padding:60px 0;font-size:14px}
.foot{color:var(--muted);font-size:12px;margin-top:20px;text-align:center}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="/logs/stats" id="backlink">← 전체 통계로</a>
<header>
<div>
<h1 id="title"></h1>
<div class="meta-sum" id="sum"></div>
</div>
<a class="btn" id="refreshBtn" href="?refresh=1">↻ 새로고침</a>
</header>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" style="display:none">표시할 생성 기록이 없습니다.</div>
<div class="foot" id="foot"></div>
</div>
<script>
const DATA = __DATA__;
function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));}
const u=DATA.user, s=DATA.summary, f=DATA.filter||{};
const qs=[]; if(f.from)qs.push("from="+f.from); if(f.to)qs.push("to="+f.to);
const dateQS=qs.length?("?"+qs.join("&")):"";
document.getElementById("backlink").href="/logs/stats"+dateQS;
document.getElementById("refreshBtn").href="?refresh=1"+(qs.length?"&"+qs.join("&"):"");
document.getElementById("title").innerHTML =
esc(u.name||u.username) + (u.username==="anonymous" ? '<span class="uid">비로그인</span>'
: `<span class="uid">@${esc(u.username)}${u.email?" · "+esc(u.email):""}</span>`);
document.getElementById("sum").innerHTML =
`<span>생성 <b>${s.gen}</b></span><span>편집 <b>${s.edit}</b></span>`
+ `<span>합계 <b>${s.total}</b></span>`
+ (s.avg_elapsed!=null?`<span>평균 소요 <b>${s.avg_elapsed}초</b></span>`:"")
+ (f.from||f.to?`<span>기간 <b>${esc(f.from||"~")} ~ ${esc(f.to||"~")}</b></span>`:"");
if(!s.configured){
document.getElementById("foot").textContent="⚠️ HF_TOKEN / HF_DATASET_REPO 미설정 — 이미지를 불러올 수 없습니다.";
}
if(s.truncated){
document.getElementById("foot").textContent=`최근 ${s.limit}건만 표시했습니다. 더 보려면 기간 필터를 사용하세요.`;
}
function imgOrNone(url, alt, tag){
if(!url) return "";
return `<div class="t"><img loading="lazy" src="${url}" alt="${esc(alt)}" onerror="this.parentNode.style.display='none'"/>${tag?`<span class="tag">${esc(tag)}</span>`:""}</div>`;
}
function card(it){
const badges=[];
badges.push(`<span class="badge ${it.is_edit?'edit':''}">${it.kind}</span>`);
if(it.shot) badges.push(`<span class="badge gray">${esc(it.shot)}</span>`);
if(it.angle) badges.push(`<span class="badge gray">${esc(it.angle)}</span>`);
if(it.gaze) badges.push(`<span class="badge gray">${esc(it.gaze)}</span>`);
if(it.mode && it.mode!=="COMFY") badges.push(`<span class="badge gray">${esc(it.mode)}</span>`);
if(it.is_edit && it.is_background_change) badges.push(`<span class="badge gray">배경변경</span>`);
if(it.elapsed!=null) badges.push(`<span class="badge time">⏱ ${it.elapsed}초</span>`);
// 입력 이미지 스트립
let inputsHtml="";
if(!it.is_edit){
const parts=[];
if(it.persona_url) parts.push(imgOrNone(it.persona_url,"persona","페르소나"));
(it.product_urls||[]).forEach((purl,i)=>{
const cat=(it.product_cats&&it.product_cats[i])?it.product_cats[i]:("제품"+(i+1));
parts.push(imgOrNone(purl,"product",cat));
});
inputsHtml = parts.join("") || `<div class="noimg">저장된 입력 이미지 없음</div>`;
} else {
inputsHtml = `<div class="noimg">편집 — 입력은 직전 결과 이미지</div>`;
}
const outImg = it.thumb_url
? `<a href="${it.output_url||it.thumb_url}" target="_blank" rel="noopener"><img loading="lazy" src="${it.thumb_url}" alt="output" onerror="this.parentNode.innerHTML='<div class="noimg" style="padding:20px">출력 이미지 없음</div>'"/></a>`
: `<a><div class="noimg" style="padding:30px 8px;text-align:center">출력 이미지 없음</div></a>`;
const promptText = it.custom_prompt && it.custom_prompt!=="(none)" ? it.custom_prompt : "";
const promptLabel = it.is_edit ? "편집 지시" : "사용자 프롬프트";
const fullToggle = it.full_prompt
? `<span class="togglefull" onclick="this.closest('.prompt').querySelector('.fullbox').classList.toggle('open');this.textContent=this.textContent.includes('▾')?'전체 프롬프트 ▸':'전체 프롬프트 ▾'">전체 프롬프트 ▸</span>`
: "";
return `<div class="card">
<div class="card-top">
<div class="outwrap">${outImg}</div>
<div class="card-body">
<div class="badges">${badges.join("")}</div>
<div class="when">${esc(it.time)} · ${esc(it.ts)}</div>
<div class="inputs"><div class="lbl">입력 이미지</div><div class="thumbs">${inputsHtml}</div></div>
</div>
</div>
<div class="prompt">
<div class="lbl"><span>${promptLabel}</span>${fullToggle}</div>
<div class="box ${promptText?'':'empty'}">${promptText?esc(promptText):"(입력한 프롬프트 없음)"}</div>
${it.full_prompt?`<div class="fullbox"><div class="box">${esc(it.full_prompt)}</div></div>`:""}
</div>
</div>`;
}
(function(){
const items=DATA.items||[];
if(!items.length){ document.getElementById("empty").style.display="block"; return; }
document.getElementById("grid").innerHTML = items.map(card).join("");
})();
</script>
</body>
</html>"""
@app.get("/index.css")
def index_css():
if os.path.exists("index.css"):
return FileResponse("index.css", media_type="text/css")
return JSONResponse(content={}, status_code=204)
@app.get("/{filename}")
def serve_root_files(request: Request, filename: str):
decoded = unquote(filename)
if decoded.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp')):
if os.path.exists(decoded) and not decoded.startswith("."):
return FileResponse(decoded)
raise HTTPException(status_code=404, detail=f"Image not found: {decoded}")
if decoded.startswith(".") or decoded.endswith((".py", ".json", ".txt", ".md", ".env")):
# 스캐너 봇의 /.env, /server.py 류 요청은 index.html 로 응답하지 않는다
raise HTTPException(status_code=404, detail="Not found")
if os.path.exists("index.html"):
return _serve_index(request)
raise HTTPException(status_code=404, detail="Not found")
# ── Image preprocessing ──────────────────────────────────────
def _shrink_image_bytes(
raw: bytes,
max_side: int = OPENAI_INPUT_MAX_SIDE,
jpeg_quality: int = OPENAI_INPUT_JPEG_QUALITY,
preserve_format_if_possible: bool = False,
) -> Tuple[bytes, str]:
"""Resize an image to `max_side` on its longest edge and re-encode as JPEG.
Rationale: face presets (~2–3 MB PNG) and user uploads blow up both upload
time and the model's input token count on `images.edit`. A 2304-long-side
JPEG at Q95 with 4:4:4 chroma typically lands at ~0.6–1.2 MB with no
visible quality loss, even after repeated edit cycles.
Why these specific knobs (verified by simulation, see comments at the top
of this file):
• max_side=2304 matches the largest portrait OUTPUT size (1536x2304 for
4K), so a generated image fed back through /api/edit is NOT downscaled.
Without this, every edit pass shrinks the image by ~25–33 % only for
the API to regenerate it back up — accumulating real detail loss.
• quality=95 lifts JPEG re-encode artifacts from ~32 dB PSNR (Q90, very
visible after 3+ edits) to ~45 dB (essentially indistinguishable).
• subsampling=0 (4:4:4) keeps full-resolution chroma. Pillow's default
below Q95 is 4:2:0, which halves color channels and accumulates
color-bleed/mosquito-noise on every edit cycle.
Idempotent in practice: feeding the encoder its own output again converges
on a fixed point — measured drift across 5 generations is ~0.06 dB PSNR.
Returns: (bytes, mime_type). Falls back to the original bytes on failure.
"""
try:
img = Image.open(BytesIO(raw))
original_format = (img.format or "").upper()
w, h = img.size
long_side = max(w, h)
if preserve_format_if_possible and long_side <= max_side:
if original_format == "PNG" and img.mode in ("RGB", "RGBA"):
return raw, "image/png"
if original_format in ("JPEG", "JPG") and img.mode == "RGB":
return raw, "image/jpeg"
# gpt-image-2 accepts RGB. Flatten alpha onto white (safer for garments
# on white backgrounds; we don't use transparency anywhere in outputs).
if img.mode in ("RGBA", "LA"):
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1])
img = bg
elif img.mode == "P":
img = img.convert("RGBA")
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1])
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
if long_side > max_side:
scale = max_side / long_side
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
img = img.resize(new_size, Image.LANCZOS)
buf = BytesIO()
img.save(
buf,
format="JPEG",
quality=jpeg_quality,
optimize=True,
progressive=False,
subsampling=0, # 4:4:4 — preserves full chroma across edit cycles
)
out = buf.getvalue()
return out, "image/jpeg"
except Exception as e:
print(f"[RESIZE] Failed ({type(e).__name__}: {e}); returning original bytes")
return raw, "image/png"
def _shrink_base64(b64_data: str) -> Tuple[str, str]:
"""Convenience: take a base64-encoded image, shrink it, return (b64, mime)."""
try:
raw = base64.b64decode(b64_data)
except Exception:
return b64_data, "image/png"
out_bytes, mime = _shrink_image_bytes(raw)
return base64.b64encode(out_bytes).decode("utf-8"), mime
# ── Server-side face presets ──────────────────────────────────
_FACE_PRESET_CACHE: Dict[str, dict] = {}
def _load_face_presets():
preset_files = [
("preset_17", "face_preset_17.png"),
("preset_18", "face_preset_18.png"),
("preset_19", "face_preset_19.png"),
]
total_before = 0
total_after = 0
for pid, filename in preset_files:
path = os.path.join(os.path.dirname(__file__) or ".", filename)
if os.path.exists(path):
with open(path, "rb") as f:
raw = f.read()
# Pre-resize once at startup; preserve PNG when already within API max side.
shrunk, mime = _shrink_image_bytes(raw, preserve_format_if_possible=True)
b64 = base64.b64encode(shrunk).decode("utf-8")
_FACE_PRESET_CACHE[pid] = {"data": b64, "mime_type": mime}
total_before += len(raw)
total_after += len(shrunk)
print(f"[FACE] Loaded preset '{pid}' from {filename} ({len(raw):,} → {len(shrunk):,} bytes, {mime})")
else:
print(f"[FACE] Preset file not found: {path}")
if total_before > 0:
saved_pct = 100 * (1 - total_after / total_before)
print(f"[FACE] Total preset memory: {total_before:,} → {total_after:,} bytes (-{saved_pct:.0f}%)")
_load_face_presets()
# ── Server-side tone reference (color palette) ────────────────
# A single static photo whose color distribution defines the target skin
# tone / color grading for generated models. Stored as a heavily mosaiced
# image so all recognizable structure (face, outfit, pose, background) is
# destroyed — only the color palette remains. The model receives this as
# the SECOND input image in MODE B and is instructed via the prompt to
# treat it strictly as a color reference.
_TONE_REF_CACHE: Dict[str, Any] = {}
def _load_tone_ref():
if not TONE_REF_ENABLED:
print("[TONE] Disabled via TONE_REF_ENABLED=0")
return
path = os.path.join(os.path.dirname(__file__) or ".", TONE_REF_FILE)
if not os.path.exists(path):
print(f"[TONE] Reference file not found: {path} — feature inactive")
return
try:
with open(path, "rb") as f:
raw = f.read()
palette = _build_tone_palette_image(raw)
if not palette:
raise ValueError("palette build failed")
b64 = palette["data"]
mime = palette["mime_type"]
_TONE_REF_CACHE["data"] = b64
_TONE_REF_CACHE["mime_type"] = mime
print(
f"[TONE] Loaded reference from {TONE_REF_FILE} "
f"(palette strip {TONE_REF_MOSAIC_W} swatches, "
f"{len(base64.b64decode(b64)):,} bytes, {mime})"
)
except Exception as e:
print(f"[TONE] Failed to load reference ({type(e).__name__}: {e}) — feature inactive")
def _get_tone_ref_part() -> Optional[dict]:
"""Return the tone-reference inline_data part, or None if unavailable/disabled."""
if not TONE_REF_ENABLED or "data" not in _TONE_REF_CACHE:
return None
return {
"inline_data": {
"data": _TONE_REF_CACHE["data"],
"mime_type": _TONE_REF_CACHE["mime_type"],
}
}
def _build_tone_palette_image(image_bytes: bytes) -> Optional[Dict[str, str]]:
"""Build a tiny horizontal palette strip — color info only, never a scene background."""
try:
src = Image.open(BytesIO(image_bytes)).convert("RGB")
grid_w, grid_h = TONE_REF_MOSAIC_W, TONE_REF_MOSAIC_H
tiny = src.resize((grid_w, grid_h), Image.LANCZOS)
swatch_w = 14
strip_h = 16
strip = Image.new("RGB", (grid_w * swatch_w, strip_h))
for x in range(grid_w):
colors = [tiny.getpixel((x, y)) for y in range(grid_h)]
r = sum(c[0] for c in colors) // grid_h
g = sum(c[1] for c in colors) // grid_h
b = sum(c[2] for c in colors) // grid_h
color = (r, g, b)
for dx in range(swatch_w):
for y in range(strip_h):
strip.putpixel((x * swatch_w + dx, y), color)
buf = BytesIO()
strip.save(buf, format="PNG")
shrunk, mime = _shrink_image_bytes(buf.getvalue())
return {
"data": base64.b64encode(shrunk).decode("utf-8"),
"mime_type": mime,
}
except Exception as e:
print(f"[TONE] Failed to build tone palette strip ({type(e).__name__}: {e})")
return None
def _extract_tone_palette_text(image_bytes: bytes) -> str:
"""Summarize dominant colors as text so the model can grade without copying a grid."""
try:
src = Image.open(BytesIO(image_bytes)).convert("RGB")
tiny = src.resize((TONE_REF_MOSAIC_W, TONE_REF_MOSAIC_H), Image.LANCZOS)
hex_colors = []
for x in range(TONE_REF_MOSAIC_W):
colors = [tiny.getpixel((x, y)) for y in range(TONE_REF_MOSAIC_H)]
r = sum(c[0] for c in colors) // len(colors)
g = sum(c[1] for c in colors) // len(colors)
b = sum(c[2] for c in colors) // len(colors)
hex_colors.append(f"#{r:02x}{g:02x}{b:02x}")
return "Target palette (grade only): " + ", ".join(hex_colors)
except Exception:
return ""
def _mosaic_image_for_tone_ref(image_bytes: bytes) -> Optional[Dict[str, str]]:
"""Backward-compatible alias — palette strip only."""
return _build_tone_palette_image(image_bytes)
def _get_user_tone_ref_part(tone_image_b64: str) -> Optional[dict]:
if not tone_image_b64 or not isinstance(tone_image_b64, str) or not tone_image_b64.startswith("data:"):
return None
parsed = _parse_base64(tone_image_b64)
palette = _build_tone_palette_image(base64.b64decode(parsed["data"]))
if not palette:
return None
return {
"inline_data": {
"data": palette["data"],
"mime_type": palette["mime_type"],
}
}
def _parse_user_tone_reference(tone_image_b64: str) -> Tuple[Optional[dict], str]:
"""Return palette strip (ORIGINAL only) and palette text for prompt grading."""
if not tone_image_b64 or not isinstance(tone_image_b64, str) or not tone_image_b64.startswith("data:"):
return None, ""
parsed = _parse_base64(tone_image_b64)
raw = base64.b64decode(parsed["data"])
palette_text = _extract_tone_palette_text(raw)
tone_part = _get_user_tone_ref_part(tone_image_b64)
return tone_part, palette_text
def _build_tone_background_lock() -> str:
return (
"TONE BACKGROUND LOCK (CRITICAL): The visible scene background MUST remain a continuous soft light-gray photography studio backdrop. "
"Do NOT render any mosaic grid, checkerboard, color tiles, palette swatches, or pattern from the tone reference as the background. "
"The tone reference influences color grade and mood only — never the background texture or layout."
)
def _build_tone_reference_instruction(
mode: str = "original",
has_face: bool = True,
palette_text: str = "",
) -> str:
palette_line = f"{palette_text}. " if palette_text else ""
background_lock = _build_tone_background_lock()
if mode == "transform":
return (
f"TONE / MOOD REFERENCE (CRITICAL): {palette_line}"
"Use this palette guidance ONLY for overall color temperature, contrast, saturation, white balance, and photographic mood. "
"Apply a similar tone and mood while preserving the source image's model identity, face, outfit, pose, framing, composition, and studio background exactly. "
"DO NOT copy garment colors, background layout, mosaic patterns, or facial identity from the tone reference. "
f"{background_lock}"
)
position = "The small palette strip immediately after the face reference" if has_face else "The small palette strip"
return (
f"TONE / MOOD REFERENCE (CRITICAL — READ CAREFULLY): {position} is NOT a person, outfit, pose, scene, or background plate. "
f"It is a tiny horizontal color palette extracted from a user reference photograph. {palette_line}"
"USE IT FOR: matching overall color temperature, white balance, contrast, saturation, and photographic mood. "
"DO NOT: copy any face, hair, outfit, garment color, pose, framing, background layout, grid, or tile pattern from this image. "
"The model's face identity must still come from the face reference — apply the tone reference to global color grade and atmosphere only. "
f"{background_lock}"
)
_load_tone_ref()
@app.get("/api/face-presets")
def get_face_presets():
preset_labels = {
"preset_17": "여성 1", "preset_18": "여성 2", "preset_19": "여성 3",
}
presets = []
for pid in _FACE_PRESET_CACHE:
presets.append({
"id": pid,
"label": preset_labels.get(pid, pid),
"url": f"/{pid.replace('preset_', 'face_preset_')}.png",
})
return {"presets": presets}
# ── Helpers ──────────────────────────────────────────────────
def _parse_base64(data_url: str) -> dict:
if ";base64," in data_url:
header, data = data_url.split(";base64,", 1)
mime = header.split(":", 1)[1] if ":" in header else "image/png"
return {"mime_type": mime, "data": data}
return {"mime_type": "image/png", "data": data_url}
def _hair_identity_lock(gender: str) -> str:
"""Front-view hair: reduce left/right asymmetry and random restyles vs face reference."""
if gender == "male":
return (
"HAIR IDENTITY (CRITICAL): Match the face reference hair precisely — same cut, length, and side volume; "
"do NOT randomize thickness, wave, or introduce asymmetric styling not visible in the reference."
)
return (
"HAIR IDENTITY LOCK (CRITICAL — match face reference): Replicate hair EXACTLY: same visible length on LEFT and RIGHT "
"(no one-side-only short cuts or 'half different haircut'), same part line (center vs side), same bangs/forehead exposure, "
"same straight/wave texture and overall volume. Do NOT randomize hair texture or layering. "
"Long hair must fall naturally past both shoulders with balanced symmetry in front view. "
"Do NOT tuck behind one ear only unless clearly shown in the reference."
)
def _resolve_image_size(payload_size: Any) -> str:
"""Default to 2K; allow 1K / 2K / 4K per request."""
if not payload_size:
return "2K"
try:
v = str(payload_size).strip().upper()
except Exception:
return "2K"
return v if v in {"1K", "2K", "4K"} else "2K"
def _openai_image_size(image_size_key: Any) -> str:
"""Map UI 1K/2K/4K to gpt-image-2 portrait ``size``.
gpt-image-2 accepts flexible resolutions; however, to guarantee
compatibility we use the officially documented portrait sizes and
fall back to `auto` for higher tiers that may fail at account level.
Officially supported portrait sizes for GPT Image family: 1024x1536.
"""
tier = _resolve_image_size(image_size_key)
if tier == "1K":
return "1024x1536"
if tier == "4K":
# gpt-image-2 accepts flexible resolutions but very large sizes may
# exceed the per-request token/pixel budget on some accounts.
# Use a safe high-res portrait; let the model handle fidelity.
return "1536x2304"
# 2K default
return "1536x2048"
def _openai_quality(image_size_key: Any) -> str:
"""Pick quality based on requested tier."""
tier = _resolve_image_size(image_size_key)
if tier == "4K":
return "high"
if tier == "2K":
return "high"
return "medium"
def _openai_prompt_and_image_files(parts: List[dict]) -> tuple:
"""Split Gemini-style content parts into a single prompt and binary image inputs.
Every inline image is passed through `_shrink_image_bytes` here. Face presets
use `preserve_format=True` so PNG bytes are not re-encoded to JPEG before API upload.
"""
text_chunks: List[str] = []
image_files: List[BytesIO] = []
total_before = 0
total_after = 0
for p in parts:
if "text" in p:
text_chunks.append(p["text"])
elif "inline_data" in p:
idata = p["inline_data"]
raw_b64 = idata["data"]
raw = base64.b64decode(raw_b64) if isinstance(raw_b64, str) else raw_b64
total_before += len(raw)
# Edit source images can preserve PNG/JPEG bytes when they already
# fit the input size; this avoids adding compression texture that
# the image model may amplify on skin.
shrunk, shrunk_mime = _shrink_image_bytes(
raw,
preserve_format_if_possible=bool(p.get("preserve_format")),
)
total_after += len(shrunk)
buf = BytesIO(shrunk)
buf.name = "image.jpg" if "jpeg" in shrunk_mime else "image.png"
image_files.append(buf)
if image_files and total_before > 0:
saved_pct = 100 * (1 - total_after / total_before)
print(f"[RESIZE] {len(image_files)} input image(s): {total_before:,} → {total_after:,} bytes (-{saved_pct:.0f}%)")
prompt = "\n\n".join(text_chunks)
# Guard against rare "prompt too long" errors from the API.
if len(prompt) > OPENAI_PROMPT_MAX_CHARS:
print(f"[OPENAI] Prompt length {len(prompt)} > {OPENAI_PROMPT_MAX_CHARS}; truncating.")
prompt = prompt[:OPENAI_PROMPT_MAX_CHARS]
return prompt, image_files
def _translate_openai_error(err: Exception) -> HTTPException:
"""OpenAI SDK / API 에러를 적절한 HTTP 상태코드 + 한글 메시지로 변환.
OpenAI 에러는 `getattr(err, "body", dict)["error"]["code"]`에 유형이 담겨 있고,
SDK가 못 받은 경우를 대비해 메시지 문자열로도 한 번 더 매칭.
"""
code = ""
msg_raw = ""
try:
body = getattr(err, "body", None) or {}
if isinstance(body, dict):
err_obj = body.get("error") or {}
code = (err_obj.get("code") or "").lower()
msg_raw = err_obj.get("message") or ""
except Exception:
pass
s = (str(err) + " " + code + " " + msg_raw).lower()
# 결제 한도 (가장 먼저 체크 — 400으로 위장되어 오는 경우가 있음)
if "billing_hard_limit_reached" in s or "billing hard limit" in s:
return HTTPException(
status_code=402,
detail="OpenAI API 결제 한도에 도달했습니다. 관리자에게 사용량 한도 상향을 요청해 주세요.",
)
if "insufficient_quota" in s:
return HTTPException(
status_code=402,
detail="OpenAI API 크레딧이 부족합니다. 결제 정보를 확인해 주세요.",
)
# Rate limit
if "rate_limit" in s or "rate limit" in s or "too many requests" in s or " 429" in s:
return HTTPException(
status_code=429,
detail="요청이 몰려 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.",
)
# 콘텐츠 정책
if "content_policy" in s or "safety_violation" in s or "safety system" in s or "moderation_blocked" in s:
return HTTPException(
status_code=400,
detail="이미지 생성이 OpenAI 콘텐츠 정책에 의해 거부되었습니다. 프롬프트나 입력 이미지를 확인해 주세요.",
)
# 인증
if "invalid_api_key" in s or "incorrect api key" in s or "authentication" in s:
return HTTPException(
status_code=401,
detail="OpenAI API 키가 유효하지 않거나 설정되지 않았습니다. 환경변수를 확인해 주세요.",
)
# 모델 접근 권한
if "model_not_found" in s or "does not have access to model" in s or "not_found" in s:
return HTTPException(
status_code=403,
detail=f"모델 '{OPENAI_IMAGE_MODEL}'에 접근할 수 없습니다. 계정 등급/모델 액세스 권한을 확인해 주세요.",
)
# 타임아웃 / 네트워크
if "timeout" in s or "timed out" in s:
return HTTPException(
status_code=504,
detail="OpenAI API 응답 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
)
if "connection" in s and ("error" in s or "refused" in s or "reset" in s):
return HTTPException(
status_code=502,
detail="OpenAI API 서버에 연결할 수 없습니다. 네트워크 상태를 확인해 주세요.",
)
# 잘못된 요청 (사이즈 외 기타)
if "invalid_request" in s or "bad request" in s:
short = (msg_raw or str(err))[:300]
return HTTPException(
status_code=400,
detail=f"요청이 OpenAI API에 의해 거부되었습니다: {short}",
)
# 그 외 (원문 메시지 일부 첨부)
short = (msg_raw or str(err))[:300]
return HTTPException(
status_code=502,
detail=f"이미지 생성 중 오류가 발생했습니다: {short}",
)
def _openai_image_generate(client: OpenAI, parts: List[dict], image_size_key: Any) -> str:
"""gpt-image-2: images.edit with reference images, else images.generate.
Important notes for gpt-image-2 (2026-04-21):
- `response_format` is NOT supported (the API always returns b64_json).
- `input_fidelity` is NOT supported — gpt-image-2 auto-processes input
images at high fidelity internally.
- `background="transparent"` is NOT supported.
- Size parameter is flexible but we use documented portrait sizes.
"""
prompt, image_files = _openai_prompt_and_image_files(parts)
size_value = _openai_image_size(image_size_key)
quality_value = _openai_quality(image_size_key)
common = {
"model": OPENAI_IMAGE_MODEL,
"prompt": prompt,
"size": size_value,
"quality": quality_value,
"n": 1,
}
try:
if image_files:
for f in image_files:
f.seek(0)
print(f"[OPENAI] images.edit | model={OPENAI_IMAGE_MODEL} | size={size_value} | quality={quality_value} | refs={len(image_files)}")
result = client.images.edit(image=image_files, **common)
else:
print(f"[OPENAI] images.generate | model={OPENAI_IMAGE_MODEL} | size={size_value} | quality={quality_value}")
result = client.images.generate(**common)
except Exception as api_err:
# Retry with `size="auto"` if the requested size is rejected.
err_str = str(api_err).lower()
is_size_err = any(k in err_str for k in ("size", "dimension", "resolution", "pixel"))
if is_size_err and size_value != "auto":
print(f"[OPENAI] Size '{size_value}' rejected: {api_err}. Retrying with size='auto'.")
common["size"] = "auto"
try:
if image_files:
for f in image_files:
f.seek(0)
result = client.images.edit(image=image_files, **common)
else:
result = client.images.generate(**common)
except Exception as retry_err:
print(f"[OPENAI] Retry failed: {retry_err}")
raise _translate_openai_error(retry_err)
else:
print(f"[OPENAI] API error: {api_err}")
raise _translate_openai_error(api_err)
if not getattr(result, "data", None):
raise HTTPException(status_code=500, detail="No image was returned by OpenAI.")
b64 = result.data[0].b64_json
if not b64:
# Some SDK versions expose URL instead; attempt to read `url` as fallback.
url = getattr(result.data[0], "url", None)
if url:
raise HTTPException(status_code=500, detail=f"OpenAI returned URL instead of b64 data: {url}")
raise HTTPException(status_code=500, detail="No image data in OpenAI response.")
return f"data:image/png;base64,{b64}"
def _get_face_parts(model_face) -> list:
parts = []
face_data = None
face_mime = None
if isinstance(model_face, str):
if model_face.startswith("preset_") and model_face in _FACE_PRESET_CACHE:
cached = _FACE_PRESET_CACHE[model_face]
face_data = cached["data"]
face_mime = cached["mime_type"]
print(f"[FACE] Using preset '{model_face}' ({len(face_data)} base64 chars)")
elif model_face.startswith("data:"):
parsed = _parse_base64(model_face)
face_data = parsed["data"]
face_mime = parsed["mime_type"]
print(f"[FACE] Using user upload ({len(face_data)} base64 chars)")
if face_data:
parts.append({
"inline_data": {"data": face_data, "mime_type": face_mime},
"preserve_format": True,
})
parts.append({"text": (
"[FACE REFERENCE IMAGE] This is the EXACT face/person to use as the model. "
"You MUST preserve this person's facial identity with extreme precision: "
"same face shape, eye shape, eyebrow shape, nose, lips, and hairstyle. "
"FACIAL SKIN (HIGHEST PRIORITY): Copy the reference face's skin EXACTLY — same even tone, same smoothness, same lighting. "
"Render face skin as one clean continuous catalog tone matching the reference. "
"Do NOT add under-eye darkness, cheek stains, nose shadows, forehead patches, pores-as-noise, mottling, speckles, or asymmetric skin discoloration. "
"Do NOT add new freckles, moles, or random dots not visible in this reference. "
"HAIRSTYLE: Match hair length, part, left–right balance, and texture to this reference — "
"do NOT invent asymmetric cuts, uneven lengths, or a different wave pattern. "
"The generated model must look like the SAME PERSON in every shot."
)})
return parts
# ── Garment length → body position mapping ──────────────────
_MODEL_HEIGHTS = {
"preset_9": 175, "preset_11": 175, "preset_12": 190, "preset_13": 185,
"preset_kids_1": 118, "preset_kids_2": 120, "preset_kids_3": 138,
"preset_kids_7": 140,
"preset_kids_4": 142, "preset_kids_5": 158, "preset_kids_6": 162,
}
_MODEL_GENDERS = {
"preset_9": "female", "preset_11": "female", "preset_12": "male", "preset_13": "male",
"preset_kids_1": "female", "preset_kids_2": "male", "preset_kids_3": "female",
"preset_kids_7": "female",
"preset_kids_4": "male", "preset_kids_5": "female", "preset_kids_6": "male",
}
_MODEL_AGE_GROUPS = {
"preset_9": "adult", "preset_11": "adult", "preset_12": "adult", "preset_13": "adult",
"preset_kids_1": "kids", "preset_kids_2": "kids", "preset_kids_3": "kids",
"preset_kids_7": "kids",
"preset_kids_4": "kids", "preset_kids_5": "kids", "preset_kids_6": "kids",
}
# elem_low=초등 저학년, elem_high=초등 고학년, middle=중학교
_MODEL_KIDS_GRADES = {
"preset_kids_1": "elem_low", "preset_kids_2": "elem_low",
"preset_kids_3": "elem_high", "preset_kids_7": "elem_high", "preset_kids_4": "elem_high",
"preset_kids_5": "middle", "preset_kids_6": "middle",
}
_KIDS_GRADE_BODY_HINTS = {
"elem_low": (
"The model is a lower-elementary child (Korean grades 1–3, approximately 7–9 years old, ~118–122cm tall). "
"Use the smallest child body scale — noticeably shorter than upper-elementary or middle-school models."
),
"elem_high": (
"The model is an upper-elementary child (Korean grades 4–6, approximately 10–12 years old, ~138–142cm tall). "
"Use a mid-height child body scale — taller than lower-elementary but clearly shorter than a middle-school teen."
),
"middle": (
"The model is a middle-school teenager (Korean grades 7–9, approximately 13–15 years old, ~158–162cm tall). "
"Use a taller youth body scale — clearly taller than elementary children but still not an adult fashion model."
),
}
_KIDS_GRADE_HEIGHT_RANGES = {
"elem_low": (118, 122),
"elem_high": (138, 142),
"middle": (158, 162),
}
def _build_kids_height_lock_prompt(kids_grade: Optional[str], model_height_cm: float) -> str:
if not kids_grade or model_height_cm <= 0:
return ""
wrong_grade_hints = {
"elem_low": "Do NOT render as upper-elementary (~138cm) or middle-school (~158cm) height.",
"elem_high": (
"Do NOT render as lower-elementary (~118cm) height — the body must look clearly taller than a 7–9 year old. "
"If the model looks like grades 1–3 stature, the output is WRONG."
),
"middle": "Do NOT render as elementary-child height (~118–142cm) — the body must look like a middle-school teen.",
}
lo, hi = _KIDS_GRADE_HEIGHT_RANGES.get(kids_grade, (model_height_cm - 2, model_height_cm + 2))
grade_label = {
"elem_low": "lower-elementary (grades 1–3)",
"elem_high": "upper-elementary (grades 4–6)",
"middle": "middle-school (grades 7–9)",
}.get(kids_grade, kids_grade)
return (
f"KIDS HEIGHT LOCK (CRITICAL): This model is {grade_label}. "
f"Total standing height MUST be approximately {model_height_cm:.0f}cm (acceptable range {lo:.0f}–{hi:.0f}cm). "
f"{wrong_grade_hints.get(kids_grade, '')} "
"In the full-body frame, taller grades must occupy more vertical body height than younger grades."
)
def _resolve_model_kids_grade(model_face, payload_kids_grade=None) -> Optional[str]:
if payload_kids_grade in _KIDS_GRADE_BODY_HINTS:
return payload_kids_grade
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_KIDS_GRADES.get(model_face)
return None
def _resolve_model_age_group(model_face, payload_age_group=None) -> str:
if payload_age_group in ("adult", "kids"):
return payload_age_group
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_AGE_GROUPS.get(model_face, "adult")
return "adult"
def _resolve_model_gender(model_face, payload_gender=None) -> str:
"""Resolve model gender: payload override > preset mapping > default."""
if payload_gender and payload_gender in ("male", "female"):
return payload_gender
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_GENDERS.get(model_face, "female")
return "female"
def _resolve_model_height(model_face, payload_height) -> float:
try:
h = float(payload_height) if payload_height else 0
if h > 0: return h
except (ValueError, TypeError): pass
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_HEIGHTS.get(model_face, 170)
return 170
def _calc_garment_endpoint(model_height_cm: float, garment_length_cm: float, category: str) -> str:
if model_height_cm <= 0 or garment_length_cm <= 0: return ""
start_map = {"상의":0.82, "아우터":0.82, "원피스":0.82, "하의":0.57, "스커트":0.57}
start_ratio = start_map.get(category, 0.57)
hem = model_height_cm * start_ratio - garment_length_cm
r = hem / model_height_cm
if r <= 0.03: pos = "reaching the floor"
elif r <= 0.07: pos = "at the ankle bone"
elif r <= 0.12: pos = "between ankle and mid-calf"
elif r <= 0.18: pos = "at mid-calf"
elif r <= 0.23: pos = "just below the knee"
elif r <= 0.28: pos = "at the knee"
elif r <= 0.33: pos = "just above the knee"
elif r <= 0.38: pos = "at mid-thigh"
elif r <= 0.43: pos = "at upper thigh (short)"
elif r <= 0.50: pos = "at hip level (very short)"
elif r <= 0.60: pos = "at the waist"
elif r <= 0.68: pos = "at the midriff (crop)"
else: pos = "above the chest (very cropped)"
return f"Model {model_height_cm:.0f}cm, garment {garment_length_cm:.0f}cm → hem MUST end {pos} (~{hem:.0f}cm from ground)."
def _build_garment_length_prompts(product_infos: list, model_height_cm: float) -> str:
if not model_height_cm or model_height_cm <= 0: return ""
guides = []
for i, info in enumerate(product_infos):
try: tl = float(info.get("totalLength", 0))
except: tl = 0
if tl > 0:
g = _calc_garment_endpoint(model_height_cm, tl, info.get("category", ""))
if g: guides.append(f"[Item {i+1} - {info.get('category','')}] {g}")
return ("GARMENT LENGTH (CRITICAL):\n" + "\n".join(guides)) if guides else ""
def _build_camera_prompt(
shot_label: str,
gender: str = "female",
age_group: str = "adult",
model_height_cm: float = 0,
kids_grade: Optional[str] = None,
) -> str:
rear_hair_rule = (
"HAIR (CRITICAL): Keep a masculine back silhouette. Hair should remain short/neutral and must NOT be styled forward over both shoulders."
if gender == "male"
else (
"HAIR (CRITICAL): This is a FEMALE model. The source image shows long hair — you MUST preserve it. "
"Keep the feminine long-hair silhouette visible from the back. Hair must stay OUTSIDE the hood and fall naturally behind/around shoulders. "
"Do NOT replace with short or male-style hair. Do NOT tie into a bun/ponytail unless explicitly requested. "
"The back-of-head hair length and style must match the long hair visible in the source."
)
)
hood_hair_rule = (
"HAIR (CRITICAL): Keep a masculine short/neutral hairstyle. Hair should stay above or around neck length and must NOT fall long over the shoulders. Keep hair OUTSIDE the hood and do NOT tuck it into the hood."
if gender == "male"
else "HAIR (CRITICAL): Keep hair OUTSIDE the hood. Do NOT tuck hair into the hood; let hair fall naturally around/over the shoulders."
)
male_full_body_silhouette_rule = (
"MALE SILHOUETTE CUE (IMPORTANT): Keep the upper torso visually broad and structured. "
"Hold the chest open with shoulders squared and slightly expanded, and keep the arms a little away from the torso so the shoulder span reads clearly. "
"Avoid a soft rounded silhouette that makes the shoulders look narrow. "
"The shoulder line should read almost horizontal and boxy, with a clean right-angled shoulder impression rather than a hanger-like downward slope. "
if gender == "male"
else ""
)
pocket_hand_rule = (
"Hand detail (CRITICAL): keep a feminine hand appearance — smooth skin, slender fingers, and soft natural contours. Avoid visible veins, pronounced tendons, or muscular hand definition."
if gender == "female"
else "Hand detail (CRITICAL): keep a natural masculine hand appearance with realistic structure and subtle veins/tendons only if naturally visible."
)
female_hand_rule = (
"FEMALE HAND AESTHETIC (CRITICAL): Hands should look refined and feminine — slim fingers, soft knuckles, smooth skin, minimal visible veins/tendons, and no bulky/masculine hand structure."
if gender == "female"
else ""
)
front_head_scale_rule = (
"Keep head size natural for a child (head proportionally larger than adult models — roughly 1/6 to 1/7 of total body height). "
"Do NOT shrink the head to adult 9-head fashion proportions."
if age_group == "kids"
else "Keep head size natural for a commercial lookbook (roughly 1/7.8 to 1/8.3 of total visible body height). "
"Avoid tiny-head fashion-illustration exaggeration and avoid ultra-long-leg distortion. "
"Keep a balanced torso-to-leg ratio so the natural waist sits near mid-frame."
)
kids_height_rule = (
f" BODY HEIGHT LOCK: {_build_kids_height_lock_prompt(kids_grade, model_height_cm)}"
if age_group == "kids" and model_height_cm > 0
else ""
)
mapping = {
"전신(정면)": f"Full body length shot, directly front view, standing straight and looking at the camera. {male_full_body_silhouette_rule}{hood_hair_rule} {_hair_identity_lock(gender)} {female_hand_rule} CANDIDATE CONSISTENCY LOCK (CRITICAL): All independently generated front candidates must use the same catalog camera template and body scale. Use a fixed lookbook camera/framing template — camera height around upper-hip/waist level, neutral perspective, and stable subject distance. {front_head_scale_rule} Keep margins consistent: about 5-6% space above the head and 6-8% below the feet. Maintain the same apparent model height, shoulder width, torso-to-leg ratio, foot scale, and body width in every candidate. Variation is allowed only in tiny hand relaxation or micro posture, never in camera zoom, crop, or body proportions.{kids_height_rule} The entire model from head to toe must be visible.",
"전신(자유포즈)": f"Full body length shot with a natural walking-inspired lookbook pose. Keep the body mostly front-facing with a subtle 8-12 degree angle and smooth weight transfer. One leg steps forward naturally with a softly bent front knee (never locked straight), while the rear leg stays slightly bent with a gentle heel lift for realistic gait flow. One hand rests naturally in a pocket and the other arm hangs loose with a small natural swing. Shoulders stay relaxed, torso upright with a subtle lean, and expression calm/confident (no smile). The pose should feel candid and effortless, not stiff or mannequin-like. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
"전신(자유포즈2)": f"Full body length shot, front-facing with a lively editorial pose. The model shifts weight clearly to one leg, creating a natural asymmetric S-curve. One arm is raised with the hand lightly touching the side/back of the head, while the other arm rests naturally near the hip or inside a pocket. The shoulders stay relaxed, torso has a slight lean, and one knee is softly bent for movement. Expression is calm and confident, looking toward the camera with a natural non-smiling look. The pose should feel like a real Korean fashion campaign cut — effortless but dynamic, not stiff. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
"전신(측면)": f"Full body length shot, 45-degree profile view, highlighting the fit and silhouette from a natural side-front perspective. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
"전신(측면포즈1)": f"Full body side-inspired lookbook pose with a soft three-quarter angle (about 30-40 degrees). Keep one leg as the weight-bearing leg and let the other leg rest slightly forward with a relaxed bend at the knee. Keep both hands naturally inside the outerwear pockets, shoulders relaxed, and chin slightly lowered for a calm editorial mood. Preserve clean side silhouette readability while keeping the entire body from head to toe visible. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule}",
"전신(측면포즈2)": f"Full body winter lookbook pose with a soft side-front angle (about 20-35 degrees). Keep both hands naturally inside the jacket or hoodie pockets, shoulders relaxed, and the head slightly turned toward camera with a calm neutral expression. Use a grounded stance: one leg placed slightly forward and the other supporting weight, with a subtle bend for a natural casual silhouette. Keep the overall mood cozy and effortless, and ensure the entire model from head to toe remains visible. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule}",
"전신(후면)": f"Full body back view shot, 180-degree rotation showing the back design, rear fit, and overall silhouette of the outfit. {rear_hair_rule} Keep hair naturally arranged so key garment back details remain visible as much as possible without forcing all hair to the front. The entire model from head to toe must be visible.",
"상반신": (
"Upper body medium-wide shot with generous studio margins. Pull the camera back slightly — NOT a tight bust crop. "
"Frame from the head down to slightly below the waist, with visible background above the head and on both sides of the shoulders. "
"DO NOT show thighs, knees, or feet."
),
"상반신(자유포즈)": (
"Upper body medium-wide shot with a slight three-quarter angle and generous studio margins. "
"The model's body is turned about 20-30 degrees from the camera with one hand placed naturally behind the hip or lower back. "
"The head turns toward the camera with a calm, neutral expression — NO smiling. "
"Pull the camera back slightly — NOT a tight bust crop. Frame from the top of the head to slightly below the waist with visible background on all sides. "
"Focus on the garment's neckline, texture, buttons, and fit. DO NOT show thighs, knees, or feet."
),
"상반신(후면)": (
f"Upper body back view medium-wide shot with generous studio margins. The model is turned 180 degrees showing the back. "
f"Pull the camera back slightly — NOT a tight crop. Frame from the top of the head to slightly below the waist with visible background above and beside the body. "
f"The full head, both shoulders, and upper hip area must be visible. {rear_hair_rule} "
f"Keep hair naturally arranged so garment back details remain visible as much as possible without forcing all hair to the front. "
f"Focus on the back design, yoke seam, neckline from behind, shoulder construction, and fabric texture. DO NOT show thighs, knees, or feet."
),
"하반신": "Lower body focused shot. The frame starts just below the chin/neck, cutting off the top of the head. Show the model's body from neck down to the feet including the full outfit, legs, and shoes. The focus should be on the lower garment fit and leg silhouette, but the top garment should also be partially visible for context.",
"디테일(상의)": (
"Close-up detail shot of the UPPER GARMENT focused on the chest and neckline area.\n"
"COMPOSITION (CRITICAL): The garment must be perfectly HORIZONTALLY CENTERED in the frame. "
"Both shoulder seams must be fully visible inside the frame with approximately EQUAL margin on LEFT and RIGHT — "
"the crop must NOT be asymmetric, and no shoulder or sleeve edge may be clipped. "
"The torso is perfectly frontal (zero rotation), with both shoulders at equal height.\n"
"CROP BOUNDARIES (STRICT): "
"TOP EDGE must sit at or just below the lower lip — do NOT show mouth, nose, or eyes; only the underside of the chin/jawline may touch the top edge. "
"BOTTOM EDGE around mid-chest to upper abdomen (roughly between the pectoral line and the bottom of the ribcage). "
"LEFT/RIGHT EDGE roughly 8–12% padding outside each shoulder seam, symmetric on both sides.\n"
"FOCUS (CRITICAL): Tack-sharp, even focus across the ENTIRE visible garment surface — fabric weave, knit stitch texture, neckline ribbing, and shoulder seam must all be crisp. "
"Think f/5.6–f/8 studio lookbook detail cut: no shallow depth-of-field blur, no soft focus, no background bokeh, no motion blur.\n"
"STYLING LOCK: Do NOT roll sleeves, fold cuffs, or alter sleeve styling; preserve original sleeve length and garment styling exactly. "
"Do NOT show full body, hands, or full arms/forearms."
),
"디테일(포켓)": f"Close-up detail shot focused on the side pocket area, similar to a lookbook pocket detail cut. Frame around the lower jacket/top hem and upper hip region so the pocket opening and surrounding seam structure are dominant. Pose detail: the hand is naturally inserted into the pocket with only part of the hand visible. {pocket_hand_rule} IMPORTANT: Keep the top untucked exactly as in the source look (NO tucking into bottoms). Preserve the original hem overlap and natural drape above the pocket area. Emphasize pocket opening shape, topstitch lines, hem line, and nearby sleeve cuff detail. Keep the face and full body out of frame.",
"디테일(신발)": "Close-up detail shot of the SHOES and lower hem area. Focus on the footwear design, shoe material, sole details, and how the bottom garment hem falls around the ankle and shoe. Frame from mid-calf down to the floor.",
}
return mapping.get(shot_label, "Full body front facing fashion photograph.")
def _build_default_shoes_prompt(age_group: str = "adult") -> str:
if age_group == "kids":
return (
"SHOES (DEFAULT): Unless the user specifies different footwear, the model MUST be wearing "
"plain white canvas sneakers — low-top, lace-up, solid all-white (white upper, white laces, white rubber sole). "
"Clean, simple kids' sneaker style similar to classic white Vans or Keds."
)
return (
"SHOES (DEFAULT): Unless the user specifies different footwear, the model MUST be wearing classic black leather loafers."
)
def _build_body_proportion_prompt(
shot_label: str,
gender: str = "female",
age_group: str = "adult",
model_face=None,
model_height_cm: float = 0,
kids_grade: Optional[str] = None,
) -> str:
"""Build body proportion guidance based on shot type, gender, and age group."""
if age_group == "kids":
grade_hint = _KIDS_GRADE_BODY_HINTS.get(kids_grade or "", (
"The model is a child. Use natural child body proportions — the head is proportionally larger "
"relative to the body compared to adult fashion models."
))
height_hint = f" Total body height MUST be approximately {model_height_cm:.0f}cm." if model_height_cm > 0 else ""
return (
f"CHILD MODEL BODY (CRITICAL): {grade_hint}{height_hint} "
"Do NOT apply adult 9-head fashion canon proportions. "
"Maintain age-appropriate limb length, torso size, and overall stature consistent with the specified school grade."
)
ratio_lock = _build_model_ratio_lock(shot_label, gender)
male_build = (
"MALE MODEL BUILD (CRITICAL): The model must have broad, well-defined shoulders — "
"shoulder width should be approximately 2.5 to 3 times the head width, creating a strong V-shaped upper body silhouette. "
"The chest should be wide and the waist relatively narrow. "
"Do NOT generate narrow or sloping shoulders. The broad-shouldered frame is essential for a masculine fashion-model look. "
"MALE TORSO/LEG BALANCE (CRITICAL): Keep a natural commercial male torso length — NOT a fashion-illustration short torso. "
"Do NOT exaggerate leg length so that the upper body looks stubby. "
"Waist-to-floor and head-to-waist should feel balanced for a real catalog model, not an elongated runway sketch."
) if gender == "male" else ""
if _is_full_body_shot(shot_label):
return (
"BODY PROPORTIONS (CRITICAL): Use a commercial Korean lookbook body — about 7.8–8.2 head heights. "
"Do NOT use extreme 9-head fashion-illustration proportions with a short torso and ultra-long legs. "
"NATURAL WAIST LOCK: The anatomical waist / natural waistband line must sit near the vertical middle of the frame "
"(about 48–52% from the image top). Do NOT push the waist high in the frame. "
"TORSO LENGTH: Keep a full, natural torso from shoulders to waist — never compress the upper body. "
"LEG LENGTH: Legs may be slightly elegant, but crotch-to-floor should be about 47–50% of total body height — "
"NOT clearly dominating the silhouette. Avoid short-torso / long-leg distortion. "
"Think SPAO / commercial catalog photography of a real model — NOT fashion-sketch anatomy. "
f"{male_build}"
"FRAMING GUIDE: The model should fill approximately 86–89% of the frame height, "
"with a stable margin of ~5–6% above the head and ~6–8% below the feet (avoid large variation between outputs). "
"CONSISTENCY LOCK: Keep camera distance and perspective stable across generations; "
"do NOT alternate between short-torso elongated legs and natural mid-waist framing. "
"Do NOT distort or compress body proportions. "
f"{ratio_lock}"
)
elif _is_upper_body_shot(shot_label):
return (
"BODY PROPORTIONS (IMPORTANT): The model must have natural, well-proportioned upper body anatomy "
"consistent with a commercial lookbook model. Shoulders, neck, and torso should appear elegant but not compressed. "
f"{male_build}"
"FRAMING GUIDE: Leave ~5% margin above the head. The frame should end slightly below the waist. "
"Do NOT distort shoulder width, neck length, or torso proportions. "
f"{ratio_lock}"
)
elif _is_lower_body_shot(shot_label):
return (
"BODY PROPORTIONS (IMPORTANT): The legs and lower body must look natural and commercial, "
"consistent with a balanced 8-head lookbook silhouette — not ultra-elongated runway legs. "
"Do NOT over-lengthen the legs relative to the implied torso. "
f"{ratio_lock}"
)
return ""
def _is_full_body_shot(shot_label: str) -> bool:
s = str(shot_label or "")
return s.startswith("전신") or s.startswith("?꾩떊") or s.startswith("?袁⑸뻿") or "Full body" in s
def _is_upper_body_shot(shot_label: str) -> bool:
s = str(shot_label or "")
return s.startswith("상반신") or s.startswith("?곷컲") or s.startswith("?怨룹뺘") or "Upper body" in s
def _build_upper_body_framing_guide() -> str:
"""Loose waist-up framing with visible studio background on all sides."""
return (
"UPPER-BODY FRAMING (LOOSE MEDIUM SHOT — NOT TIGHT): Pull the camera back for a relaxed waist-up composition. "
"Do NOT use a tight bust/portrait crop that fills the frame edge-to-edge. "
"The model should occupy about 68-75% of frame height with visible soft light-gray studio background above the head, beside the shoulders, and below the waist crop. "
"Layout guide: top of hair at 10-14% from image top, chin around 32-36%, shoulder line around 42-48%, "
"waistline around 72-78%, bottom edge at upper hips/waistband around 78-84% — NEVER show mid-thigh or lower. "
"Horizontal: leave about 15-20% background margin on each side of the shoulders. "
"Keep this same loose framing scale across all upper-body variants (front, free pose, back)."
)
def _is_lower_body_shot(shot_label: str) -> bool:
s = str(shot_label or "")
return s.startswith("하반신") or s.startswith("?섎컲") or s.startswith("??롮뺘") or "Lower body" in s
def _build_even_studio_lighting_prompt(scope: str = "") -> str:
label = f" — {scope}" if scope else ""
return (
f"LIGHTING (CRITICAL{label}): Use soft, even, low-contrast studio lighting across the entire frame. "
"Facial lighting must be uniformly filled with minimal left/right falloff. "
"Do NOT use strong directional key light from one side. Do NOT create cheek shadows, nose shadows, under-eye patches, or asymmetric facial tone. "
"Keep skin tone smooth and continuous from forehead to jaw on both sides. "
"Garment lighting may have gentle depth, but the face must read as evenly lit catalog skin — not remapped or relit."
)
def _build_even_face_lighting_override(scope: str = "") -> str:
label = f" — {scope}" if scope else ""
return (
f"FACE LIGHTING OVERRIDE (CRITICAL{label}): "
"Render facial skin as a clean, continuous, evenly lit tone with NO mottling, blotches, dappled patches, speckles, stains, or asymmetric shadow. "
"Match the reference face identity exactly; do NOT re-paint skin texture or add new marks. "
"A uniform lookbook face tone is more important than visible pores or directional shadow on the face."
)
def _build_face_skin_lock_prompt(has_face: bool = False) -> str:
if not has_face:
return ""
return (
"FACE SKIN LOCK (HIGHEST PRIORITY): The face reference image defines the exact skin appearance. "
"Copy the reference face skin tone and smoothness exactly — one even catalog complexion with no patchy relighting. "
"Do NOT invent under-eye circles, cheek stains, nose-bridge smudges, forehead blotches, pore noise, or asymmetric shadow. "
"Do NOT apply fabric-style texture, grain, or wrinkle detail to facial skin. "
"If the output face skin looks uneven, blotchy, or dirty compared to the reference, the result is WRONG."
)
def _build_wall_shadow_block() -> str:
return """WALL SHADOW (MANDATORY):
Add an almost non-existent wall shadow behind the model.
- Wall shadow must stay at near-zero opacity and very broad diffusion.
- Do NOT show a readable human silhouette on the wall (no head/shoulder outline).
- Blend the shadow into the wall tone until it is barely detectable even on close look.
- Keep shadow offset minimal and very close to the body; avoid lateral spread.
- Keep only a near-invisible micro contact shadow under the shoes.
- This is a minimal depth cue only, effectively close to no shadow.
- These wall-shadow rules apply to the BACKGROUND ONLY — do NOT cast readable shadows onto the face."""
_FABRIC_REALISM_SUFFIX = (
"These fabric texture rules apply to GARMENTS ONLY. "
"Do NOT add weave, grain, wrinkles, or micro-texture to facial skin."
)
def _build_transform_lighting_prompt(shot_label: str) -> str:
if _is_upper_body_shot(shot_label):
return _build_even_studio_lighting_prompt("UPPER-BODY")
return (
"LIGHTING: Maintain directional studio lighting from the LEFT side — the left side of the model should be brighter, "
"with soft natural shadow falloff on the right side. Do NOT flatten the lighting."
)
def _build_transform_face_lighting_override(shot_label: str) -> str:
if _is_upper_body_shot(shot_label):
return _build_even_face_lighting_override("UPPER-BODY")
return (
"FACE LIGHTING OVERRIDE: Keep the face clean and evenly filled even under directional lighting. "
"Do NOT create mottled, blotchy, dappled, patchy, speckled, stained, or uneven shadows on the face. "
"Facial skin tone must remain continuous and natural, with gentle fill on the shadow side. "
"For full-body lookbook shots, prioritize a clean even face tone over visible micro skin texture."
)
def _build_model_ratio_lock(shot_label: str, gender: str = "female") -> str:
"""Common proportion/camera lock to reduce body-ratio drift between cuts."""
shoulder_rule = (
"Male shoulder span should remain about 2.5-3.0 head widths with a stable V-shaped torso. "
if gender == "male"
else "Female shoulder span should remain about 2.0-2.25 head widths with a slim editorial torso. "
)
return (
"MODEL RATIO LOCK (CRITICAL): Use one fixed commercial lookbook body template across all outputs for this session. "
"Keep the same apparent model height, head size, shoulder width, neck length, torso length, waist height, hip height, knee height, ankle position, and foot scale from cut to cut. "
"The head must stay about 1/7.8 to 1/8.3 of total visible body height; never switch to a tiny 9-head fashion-sketch head. "
"WAIST HEIGHT LOCK: The natural waist / belt line must stay near mid-frame (about 48-52% from image top) across candidates — "
"do NOT raise the waist and elongate the legs. "
"Leg length from crotch to floor should remain about 47-50% of total body height, with the knee at a consistent vertical position. "
f"{shoulder_rule}"
"Use the same lookbook camera template every time: neutral lens, no wide-angle stretch, camera around waist/upper-hip height, straight verticals, and stable subject distance. "
"Changing pose, angle, crop, or background may move limbs or change visible area, but must NOT change the model's body scale, head-to-body ratio, leg-to-torso ratio, or overall stature."
)
def _build_crop_template_lock(shot_label: str, age_group: str = "adult") -> str:
"""Common framing lock so shot variants keep the same visual scale."""
if _is_full_body_shot(shot_label):
if age_group == "kids":
return (
"FULL-BODY CROP TEMPLATE (CRITICAL): Use a fixed child lookbook framing box. "
"Keep natural child head-to-body ratio (head proportionally larger than adult fashion models). "
"Keep 5-6% headroom and 6-8% footroom. The full figure from head to toe must be visible. "
"Do NOT shrink the head to adult 9-head fashion proportions."
)
return (
"FULL-BODY CROP TEMPLATE (CRITICAL): Use a fixed catalog mannequin box for every independently generated full-body image. "
"Keep the head top, shoulders, waistline, knees, ankles, and feet at the same approximate vertical scale in every candidate. "
"Use this fixed vertical layout: top of hair at 5-6% from the image top, shoulder line around 18-22%, "
"natural waist / waistband around 48-52% (near mid-frame — NEVER raise waist to ~40%), "
"crotch/shorts rise around 52-56%, knees around 72-76%, soles/feet around 93-94%. "
"Use this fixed horizontal layout: body centerline at 50% image width, shoulder span about 17-21% of image width for female models and 21-25% for male models, hip span stable, feet centered and not cropped. "
"The full figure must occupy about 86-89% of image height with 5-6% headroom and 6-8% footroom. "
"Do NOT zoom in or zoom out between full-body candidates. Do NOT make the head larger, legs longer, feet smaller, or body narrower/wider to create variation. "
"Do NOT shorten the torso or raise the waistline to create a long-leg look. "
"For dynamic poses, fit the pose inside the same body-height box instead of changing the camera distance. "
"A walking/free pose may change limb angles, but the top of hair and soles must stay on the same vertical crop marks."
)
if _is_upper_body_shot(shot_label):
return _build_upper_body_framing_guide()
if _is_lower_body_shot(shot_label):
return (
"LOWER-BODY CROP TEMPLATE (CRITICAL): Keep lower-body magnification consistent with the source model. "
"Do NOT zoom the legs or feet larger/smaller than the source scale."
)
return ""
def _build_transform_crop_box_lock(shot_label: str) -> str:
"""Extra crop lock for transform shots derived from a selected reference cut."""
s = str(shot_label or "")
applies_full_body = (
s.startswith("전신(자유포즈")
or s.startswith("전신(측면")
or s.startswith("전신(후면")
or s.startswith("?꾩떊(?먯쑀")
or s.startswith("?꾩떊(痢")
or s.startswith("?꾩떊(?꾨")
)
applies_upper_body = (
s == "상반신"
or s.startswith("상반신(자유포즈")
or s == "?곷컲"
or s.startswith("?곷컲")
or s == "?怨룹뺘"
or s.startswith("?怨룹뺘")
)
if applies_full_body:
extra = (
"For the full-body free-pose-2 variant specifically, do not enlarge the upper body or face to emphasize the raised-arm pose; keep it the same size as the selected front image. "
if s.startswith("전신(자유포즈2") or "자유포즈2" in s
else ""
)
return (
"SELECTED FRONT CROP BOX LOCK (HIGHEST PRIORITY FOR THIS TRANSFORM): "
"Use the first image's full-body crop box as the fixed camera frame. "
"Do not recompose, zoom in, zoom out, change camera distance, or change lens perspective. "
"Keep the top of hair and soles/feet at the same approximate pixel positions as the first image. "
"Keep shoulder line, waistline, hip line, knee height, ankle height, body centerline, and foot scale aligned to the first image as much as the new pose/angle allows. "
"Only pose/angle may change. The model's apparent height, head size, leg-to-torso ratio, body width, and overall scale must remain the same. "
"Fit the new pose inside the existing crop box instead of changing the crop box. "
f"{extra}"
)
if applies_upper_body:
return (
"SELECTED UPPER-BODY CROP BOX LOCK (HIGHEST PRIORITY FOR THIS TRANSFORM): "
"Use one fixed loose upper-body crop box for upper-body front, free-pose, and back shots. "
"Do not recompose to a tighter face crop, zoom in, or fill the frame edge-to-edge with the body. "
"Keep top-of-hair, face size, chin height, shoulder line, torso width, waistline, and bottom crop at the same approximate pixel positions across upper-body shots. "
+ _build_upper_body_framing_guide()
+ " The free pose may rotate shoulders/torso or move one arm, but must stay inside the same loose crop box."
)
return ""
def _build_transform_instruction(shot_label: str, gender: str = "female") -> str:
"""PICasso-style: transform the front shot into a different angle/crop."""
rear_hair_rule = (
"HAIR (CRITICAL): Keep a masculine back silhouette. Hair should remain short/neutral and must NOT be styled forward over both shoulders. "
if gender == "male"
else (
"HAIR (CRITICAL): This is a FEMALE model. The source image shows long hair — you MUST preserve it in the back view. "
"Keep the feminine long-hair silhouette visible from behind. Hair must stay OUTSIDE the hood and fall naturally behind/around shoulders. "
"Do NOT replace with short or male-style hair. Do NOT tie into a bun/ponytail unless explicitly requested. "
"The back-of-head hair length and style must match the long hair visible in the source."
)
)
hood_hair_rule = (
"HAIR (CRITICAL): Keep a masculine short/neutral hairstyle. Hair should stay above or around neck length and must NOT fall long over the shoulders. Keep hair OUTSIDE the hood and do NOT tuck it into the hood. "
if gender == "male"
else "HAIR (CRITICAL): Keep hair OUTSIDE the hood. Do NOT tuck hair into the hood; let hair fall naturally around/over the shoulders. "
)
male_full_body_silhouette_rule = (
"MALE SILHOUETTE CUE (IMPORTANT): Keep the upper torso visually broad and structured. "
"Hold the chest open with shoulders squared and slightly expanded, and keep the arms a little away from the torso so the shoulder span reads clearly. "
"Avoid a soft rounded silhouette that makes the shoulders look narrow. "
"The shoulder line should read almost horizontal and boxy, with a clean right-angled shoulder impression rather than a hanger-like downward slope. "
if gender == "male"
else ""
)
pocket_hand_rule = (
"Hand detail (CRITICAL): keep a feminine hand appearance — smooth skin, slender fingers, and soft natural contours. Avoid visible veins, pronounced tendons, or muscular hand definition. "
if gender == "female"
else "Hand detail (CRITICAL): keep a natural masculine hand appearance with realistic structure and subtle veins/tendons only if naturally visible. "
)
female_hand_rule = (
"FEMALE HAND AESTHETIC (CRITICAL): Hands should look refined and feminine — slim fingers, soft knuckles, smooth skin, minimal visible veins/tendons, and no bulky/masculine hand structure. "
if gender == "female"
else ""
)
hair_front_lock = (
"HAIR LOCK (ABSOLUTE): Match reference hair exactly — same cut length and side volume; "
"do NOT randomize thickness or asymmetric styling not shown in the reference."
if gender == "male"
else (
"HAIR LOCK (ABSOLUTE — non-negotiable): Copy hair EXACTLY from the reference — same visible length on LEFT and RIGHT, "
"same part line, same texture (straight/wavy) and volume. Do NOT asymmetrically shorten one side, do NOT change wave pattern, "
"do NOT restyle or thin one side only. Long hair must fall symmetrically past the shoulders as in the reference."
)
)
mapping = {
"전신(정면)": (
"Re-render this full-body FRONT studio fashion photo using the reference image as the ground truth. "
"BODY / HEIGHT LOCK (NON-NEGOTIABLE): Keep the EXACT same body height, child age scale, head-to-body ratio, "
"crop magnification, and vertical footprint as the reference. Do NOT shrink the model to a younger/shorter grade. "
"Permitted micro-variations ONLY: subtle lighting balance on face and clothing, tiny shoulder relaxation, neutral hand placement, "
"or a sub-2° stance shift — remain a straight-on full-body lookbook framing. "
f"{hair_front_lock} "
"Keep the EXACT same model identity, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(자유포즈)": (
"Transform this front-facing full body shot into a NATURAL WALKING-INSPIRED LOOKBOOK POSE. "
"Keep the body mostly front-facing with a subtle 8-12 degree angle and smooth dynamic weight transfer. "
"Place one leg stepping forward with a softly bent front knee (never straight-locked), and keep the rear leg slightly bent with a light heel lift. "
"Set one hand naturally in a pocket and keep the other arm relaxed with a small natural swing. "
"Keep shoulders soft, torso mostly upright with a subtle natural lean, and expression calm/confident (NO smiling). "
"The pose should feel candid, effortless, and alive — NOT rigid, NOT mannequin-like, and NOT frozen. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(자유포즈2)": (
"Transform this front-facing full body shot into a DYNAMIC NATURAL FASHION POSE. "
"CAMERA / SCALE LOCK (NON-NEGOTIABLE): Keep the exact same camera distance, crop box, body height in frame, head size, shoulder width, waist height, leg length, and foot scale as the source front shot. Do NOT enlarge the model to make the pose feel more dynamic. "
"Shift body weight clearly onto one leg to create a natural asymmetric S-curve and subtle hip tilt. "
"Raise one arm gently near the side of the head (near temple/ear) while keeping the elbow compact and inside the original body-width frame; do not lift the arm so high that the crop needs to zoom out or the upper body becomes larger. Keep the other arm relaxed near the hip or inside a pocket. "
"Keep the shoulders soft, add a slight torso lean, and let one knee bend naturally for movement. "
"The expression is calm, cool, and neutral — looking toward the camera with understated confidence. NO smiling. "
"The pose should feel like a Korean lookbook campaign cut — effortless but alive, never mannequin-like. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(측면)": (
"Transform this front-facing full body shot into a SLIGHT ANGLED VIEW. "
"Rotate the model's BODY approximately 15–25 degrees to create a subtle side-front silhouette while keeping the FACE looking directly at the camera. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(측면포즈1)": (
"Transform this front-facing full body shot into a SIDE-INSPIRED THREE-QUARTER LOOKBOOK POSE. "
"Rotate the body to about 30-40 degrees. Keep one leg as the weight-bearing leg and place the other leg slightly forward with a soft natural bend. "
"Place both hands naturally inside outerwear pockets, keep shoulders relaxed, and keep the mood calm/editorial. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(측면포즈2)": (
"Transform this front-facing full body shot into a WINTER LOOKBOOK SIDE-FRONT POSE inspired by a casual cozy campaign cut. "
"Rotate the body to about 20-35 degrees side-front angle (NOT full side profile). "
"Place both hands naturally into jacket/hoodie pockets, keep shoulders relaxed, and keep the head slightly turned toward camera with a calm neutral expression. "
"Set a grounded stance with one leg slightly forward and the other weight-bearing, with a subtle natural knee bend. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(후면)": (
"Transform this front-facing full body shot into a BACK VIEW. "
"Rotate the model 180 degrees to show the back of the exact same outfit. "
f"{rear_hair_rule}"
"Keep hair naturally arranged so key garment back details remain visible as much as possible, without forcing all hair forward. "
"Keep the same body type, hair style, shoes, and soft light-gray studio background. "
"Show the back details of the garment clearly. Head to toe must be visible."
),
"상반신": (
"CROP AND REFRAME this image into an UPPER BODY ONLY shot with a LOOSE medium-wide composition. "
"ZOOM OUT slightly — do NOT create a tight bust/portrait crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
"The output image MUST show ONLY the upper body — from the full head and hair to slightly below the waistline, revealing the upper hips and waistband. "
"ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
"Pose direction (IMPORTANT): keep the model mostly front-facing with a subtle natural asymmetry — one shoulder slightly lowered, torso turned about 5-10 degrees, and both arms relaxed naturally at the sides. "
"Keep the expression calm and effortless, avoiding stiff mannequin posture. "
"Focus on the fit, texture, and silhouette of the top garment while keeping natural facial detail. "
"Keep the same body type, hair style, and soft light-gray studio background."
),
"상반신(자유포즈)": (
"CROP AND REFRAME this full body image into an UPPER BODY ONLY shot with a LOOSE medium-wide composition. "
"ZOOM OUT slightly — do NOT create a tight bust/portrait crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
"The output image MUST show ONLY the upper body — from the top of the head to slightly below the waist. "
"ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
"The model's body is turned about 20-30 degrees from the camera, with one hand placed naturally behind the hip or lower back. "
"The head turns toward the camera with a calm, cool, neutral expression — NO smiling. "
"Focus on the garment's neckline, fabric texture, buttons, and overall fit. "
"Keep the EXACT same model, face, hair, outfit, and soft light-gray studio background."
),
"상반신(후면)": (
"CROP AND REFRAME this image into an UPPER BODY BACK VIEW ONLY with a LOOSE medium-wide composition. "
"ZOOM OUT slightly — do NOT create a tight back crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
"The output image MUST show ONLY the upper body from behind — from the top of the head to slightly below the waist. "
"ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
"Rotate the model 180 degrees to show the back of the upper garment. "
f"{rear_hair_rule}"
"Keep hair naturally arranged so key upper-garment back details remain visible as much as possible, without forcing all hair forward. "
"Focus on the back design, yoke seam, shoulder construction, and fabric texture. "
"Keep the EXACT same model, hair, outfit, and soft light-gray studio background."
),
"하반신": (
"Transform this image into a LOWER BODY CLOSE-UP. "
"Frame the camera to focus on the model from the waistline or belt line down to the feet. "
"Compose the shot so the lower garment and leg silhouette dominate the frame. "
"Keep the same body type, hair style, shoes, and soft light-gray studio background. "
"Do not show the chest, shoulders, or face. "
),
"디테일(상의)": (
"Transform this image into a CLOSE-UP DETAIL SHOT of the UPPER GARMENT focused on the chest and neckline area.\n"
"COMPOSITION (CRITICAL): The garment must be perfectly HORIZONTALLY CENTERED in the new crop. "
"Both shoulder seams must be fully visible inside the frame with approximately EQUAL margin on LEFT and RIGHT — "
"no asymmetric crop, and no shoulder or sleeve edge may be clipped off either side. "
"Keep torso perfectly frontal (zero rotation), both shoulders at equal height.\n"
"CROP BOUNDARIES (STRICT): "
"TOP EDGE at or just below the lower lip — do NOT show mouth, nose, or eyes; only the underside of the chin/jawline may touch the top edge. "
"BOTTOM EDGE around mid-chest to upper abdomen (between the pectoral line and the bottom of the ribcage). "
"LEFT/RIGHT EDGE roughly 8–12% padding outside each shoulder seam, symmetric on both sides.\n"
"FOCUS (CRITICAL): Tack-sharp, even focus across the ENTIRE visible garment surface — fabric weave, knit stitch, neckline ribbing, and shoulder seam must all be crisp. "
"f/5.6–f/8 studio lookbook detail cut: no shallow DOF blur, no soft focus, no bokeh, no motion blur.\n"
"STYLING LOCK (CRITICAL): Do NOT roll sleeves, fold cuffs, or alter sleeve styling; keep original sleeve length and garment styling exactly. "
"Do NOT show hands, full arms/forearms, or the full body. "
"Keep the same soft light-gray studio background."
),
"디테일(포켓)": (
"Transform this image into a CLOSE-UP DETAIL SHOT centered on the side POCKET area, matching a lookbook pocket composition. "
"Crop and reframe around lower top/jacket hem to upper-hip region so the pocket opening is the visual center. "
"Pose detail (IMPORTANT): place the hand naturally inside the pocket, showing only a partial hand emerging from the opening. "
f"{pocket_hand_rule}"
"Styling lock (CRITICAL): keep the top untucked exactly as-is; DO NOT tuck the top into the bottoms. Preserve natural hem overlap and drape above the pocket. "
"Highlight pocket opening curve, seam lines, edge stitching, hem line, and adjacent sleeve cuff/fabric texture. "
"Do NOT show face or full body; keep the crop tight and product-detail focused. "
"Keep the same soft light-gray studio background. "
),
"디테일(신발)": (
"Transform this image into a CLOSE-UP DETAIL SHOT of the SHOES and LOWER HEM. "
"Crop and reframe to focus on the footwear, shoe material, sole, and how the garment hem falls around the ankle. "
"Frame from mid-calf down to the floor. "
"Keep the same soft light-gray studio background. "
),
}
return mapping.get(shot_label, "")
def _build_sub_accessory_prompt(
sub_product_slots: list,
sub_product_images: list,
accessory_image_start: int = 1,
) -> tuple[str, bool]:
"""Build accessory instructions for sub-product reference images (제품 1·2 악세사리)."""
if not sub_product_images or not sub_product_slots:
return "", False
lines = []
image_idx = accessory_image_start
for slot in sub_product_slots:
if not isinstance(slot, dict):
continue
product_index = int(slot.get("productIndex", 0)) + 1
image_count = int(slot.get("imageCount", 1))
if image_count > 1:
image_ref = f"Images {image_idx}–{image_idx + image_count - 1}"
else:
image_ref = f"Image {image_idx}"
view_desc = "front view" + (" and back view" if image_count > 1 else "")
lines.append(
f"- {image_ref}: Accessory for Clothing Item {product_index} (main garment #{product_index}). "
f"Copy this accessory from the reference ({view_desc}) and wear/style it naturally with Clothing Item {product_index}. "
f"The accessory MUST be visible in the final photo — do NOT omit it."
)
image_idx += image_count
if not lines:
return "", False
accessory_block = (
"ACCESSORIES (CRITICAL — MUST APPEAR IN OUTPUT):\n"
+ "\n".join(lines)
+ "\n- The model wears the main garments PLUS these accessory items. Accessories are add-ons to Clothing Items 1–2, not replacements.\n"
+ "- Reproduce each accessory's shape, color, material, and placement as shown in its reference image(s).\n"
+ "- Do NOT add any other accessories beyond those reference images."
)
return accessory_block, True
def _build_input_image_order_guide(
has_face: bool,
has_tone_ref: bool,
product_image_count: int,
sub_product_slots: list,
sub_product_images: list,
has_background: bool,
) -> str:
idx = 1
lines = ["INPUT IMAGE ORDER (CRITICAL — use the correct reference for each task):"]
if has_face:
lines.append(f"- Image {idx}: FACE reference (model identity only)")
idx += 1
if has_tone_ref:
lines.append(f"- Image {idx}: TONE palette strip (color grade only — NOT clothing or background)")
idx += 1
if product_image_count:
end = idx + product_image_count - 1
range_label = f"Images {idx}–{end}" if product_image_count > 1 else f"Image {idx}"
lines.append(f"- {range_label}: MAIN GARMENT product photos (wear these exact garments)")
idx += product_image_count
if sub_product_images and sub_product_slots:
for slot in sub_product_slots:
if not isinstance(slot, dict):
continue
product_index = int(slot.get("productIndex", 0)) + 1
image_count = int(slot.get("imageCount", 1))
end = idx + image_count - 1
range_label = f"Images {idx}–{end}" if image_count > 1 else f"Image {idx}"
lines.append(f"- {range_label}: ACCESSORY for Clothing Item {product_index}")
idx += image_count
if has_background:
lines.append(f"- Image {idx}: BACKGROUND plate")
return "\n".join(lines)
def _build_single_image_composition_lock() -> str:
"""Prevent collage/grid/contact-sheet outputs when users ask for variety."""
return (
"SINGLE-IMAGE COMPOSITION (CRITICAL — HIGHEST PRIORITY):\n"
"- Output exactly ONE full-frame photograph.\n"
"- Show exactly ONE person, ONE pose, ONE expression, ONE camera angle.\n"
"- Do NOT create a grid, collage, contact sheet, split-screen, triptych, panel layout, "
"storyboard, or multiple thumbnail crops of the same model in one image.\n"
"- Do NOT duplicate the model or show before/after / variation / expression panels.\n"
"- The entire canvas must read as a single commercial fashion photo, not a compilation."
)
def _sanitize_custom_prompt_for_single_image(raw: str) -> str:
"""Reframe prompts that often make the model output multi-panel layouts."""
if not raw or not str(raw).strip():
return ""
text = str(raw).strip()
single_expression_note = (
"Use ONE subtle natural expression in this single photograph "
"(do NOT show multiple expressions or faces in one image)"
)
single_shot_note = (
"Generate one single shot only "
"(do NOT put multiple cuts/poses in one frame)"
)
replacements = [
(r"표정\s*(이\s*)?(다양|여러|다르)[^\./,]*", single_expression_note),
(r"다양한\s*표정[^\./,]*", single_expression_note),
(r"여러\s*(가지\s*)?표정[^\./,]*", single_expression_note),
(r"(diverse|various|multiple|different)\s+expressions?[^\./,]*", single_expression_note),
(r"expression\s+(variety|variations?|options?)[^\./,]*", single_expression_note),
(r"여러\s*컷[^\./,]*", single_shot_note),
(r"여러\s*장[^\./,]*", single_shot_note),
(r"한\s*장\s*안\s*에\s*여러[^\./,]*", single_shot_note),
(r"(multiple|several|various)\s+(shots?|cuts?|poses?|looks?|frames?)[^\./,]*", single_shot_note),
(r"contact\s*sheet[^\./,]*", single_shot_note),
(r"grid\s*layout[^\./,]*", single_shot_note),
(r"collage[^\./,]*", single_shot_note),
]
sanitized = text
for pattern, repl in replacements:
sanitized = re.sub(pattern, repl, sanitized, flags=re.IGNORECASE)
sanitized = re.sub(r"\s*/\s*/+", " / ", sanitized)
sanitized = re.sub(r"\s{2,}", " ", sanitized).strip(" /")
return sanitized.strip()
# ── HF Dataset Push (background) ─────────────────────────────
def _make_jpg_thumb(img_bytes: bytes, max_side: int = 512) -> BytesIO:
try:
from PIL import Image
im = Image.open(BytesIO(img_bytes)).convert("RGB")
im.thumbnail((max_side, max_side), Image.LANCZOS)
bio = BytesIO()
im.save(bio, "JPEG", quality=85)
bio.seek(0)
return bio
except Exception:
return BytesIO(img_bytes)
def _sanitize_upload_filename(name: Optional[str], fallback: str) -> str:
"""Keep a readable original basename while making it safe for HF paths."""
raw = (name or "").strip()
base = os.path.basename(raw.replace("\\", "/")) if raw else ""
if not base or base in (".", ".."):
base = fallback
# Keep letters/digits/._- and common unicode letters; collapse everything else.
cleaned = re.sub(r"[^\w.\-가-힣]+", "_", base, flags=re.UNICODE)
cleaned = cleaned.strip("._") or fallback
# Avoid path traversal leftovers and overly long names.
cleaned = cleaned.replace("..", "_")
if len(cleaned) > 120:
stem, ext = os.path.splitext(cleaned)
cleaned = stem[:100] + ext[:20]
return cleaned
def _unique_filenames(names: list, fallback_prefix: str) -> list:
used = set()
out = []
for i, name in enumerate(names):
safe = _sanitize_upload_filename(name, f"{fallback_prefix}_{i}")
stem, ext = os.path.splitext(safe)
candidate = safe
n = 2
while candidate.lower() in used:
candidate = f"{stem}_{n}{ext}"
n += 1
used.add(candidate.lower())
out.append(candidate)
return out
def _push_to_hub_sync(record: dict, image_bytes: bytes, ts: str,
product_images_b64: list = None, face_b64: str = None,
product_filenames: list = None, face_filename: str = None,
sub_product_images_b64: list = None, sub_product_filenames: list = None):
"""Run in background thread — never blocks the API response."""
if not HF_TOKEN or not HF_DATASET_REPO:
return
try:
from huggingface_hub import HfApi, CommitOperationAdd
except ImportError:
return
api = HfApi(token=HF_TOKEN)
try:
api.create_repo(HF_DATASET_REPO, repo_type="dataset", exist_ok=True)
except Exception:
pass
day = ts[:8]
ops = []
filename = f"fashion_{ts}_0.jpg"
ops.append(CommitOperationAdd(path_in_repo=f"images/{day}/{filename}", path_or_fileobj=BytesIO(image_bytes)))
ops.append(CommitOperationAdd(path_in_repo=f"thumbs/{day}/{filename}", path_or_fileobj=_make_jpg_thumb(image_bytes)))
saved_product_paths = []
if product_images_b64:
names = list(product_filenames or [])
while len(names) < len(product_images_b64):
names.append(f"product_{len(names)}.png")
names = _unique_filenames(names[:len(product_images_b64)], "product")
print(f"[HUB] Product filenames: {names}")
for i, img_b64 in enumerate(product_images_b64):
try:
if isinstance(img_b64, str) and ";base64," in img_b64:
raw = base64.b64decode(img_b64.split(";base64,", 1)[1])
# Keep original filename; nest under ts folder so gallery index can match.
path = f"inputs/product/{day}/{ts}/{names[i]}"
ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw)))
saved_product_paths.append(path)
except Exception:
pass
saved_sub_paths = []
if sub_product_images_b64:
names = list(sub_product_filenames or [])
while len(names) < len(sub_product_images_b64):
names.append(f"sub_product_{len(names)}.png")
names = _unique_filenames(names[:len(sub_product_images_b64)], "sub_product")
for i, img_b64 in enumerate(sub_product_images_b64):
try:
if isinstance(img_b64, str) and ";base64," in img_b64:
raw = base64.b64decode(img_b64.split(";base64,", 1)[1])
path = f"inputs/product/{day}/{ts}/sub/{names[i]}"
ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw)))
saved_sub_paths.append(path)
except Exception:
pass
saved_persona_path = None
if face_b64:
try:
raw_face = base64.b64decode(face_b64)
persona_name = _sanitize_upload_filename(face_filename, f"persona_{ts}.jpg")
# Ensure persona filename still contains ts for gallery matching when user name lacks it.
if ts not in persona_name:
stem, ext = os.path.splitext(persona_name)
persona_name = f"{stem}_{ts}{ext or '.jpg'}"
path = f"inputs/persona/{day}/{persona_name}"
ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw_face)))
saved_persona_path = path
except Exception:
pass
record = dict(record or {})
if saved_product_paths:
record["product_input_paths"] = saved_product_paths
record["product_input_filenames"] = [os.path.basename(p) for p in saved_product_paths]
if saved_sub_paths:
record["sub_product_input_paths"] = saved_sub_paths
record["sub_product_input_filenames"] = [os.path.basename(p) for p in saved_sub_paths]
if saved_persona_path:
record["persona_input_path"] = saved_persona_path
record["persona_input_filename"] = os.path.basename(saved_persona_path)
ops.append(CommitOperationAdd(
path_in_repo=f"logs/{day}/fashion_{ts}.json",
path_or_fileobj=BytesIO(json.dumps(record, ensure_ascii=False, indent=2).encode("utf-8"))
))
try:
api.create_commit(repo_id=HF_DATASET_REPO, repo_type="dataset", operations=ops,
commit_message=f"Add lookbook generation: {ts}")
print(f"[HUB] ✅ Pushed to {HF_DATASET_REPO} ({len(ops)} files)")
except Exception as e:
print(f"[HUB] Upload error: {e}")
def _push_to_hub_background(
record, image_bytes, ts,
product_images_b64=None, face_b64=None,
product_filenames=None, face_filename=None,
sub_product_images_b64=None, sub_product_filenames=None,
):
"""Fire-and-forget: push to hub in a background thread."""
t = threading.Thread(
target=_push_to_hub_sync,
args=(record, image_bytes, ts),
kwargs={
"product_images_b64": product_images_b64,
"face_b64": face_b64,
"product_filenames": product_filenames,
"face_filename": face_filename,
"sub_product_images_b64": sub_product_images_b64,
"sub_product_filenames": sub_product_filenames,
},
daemon=True,
)
t.start()
print(f"[HUB] Background upload started for {ts}")
# ══════════════════════════════════════════════════════════════
# API: Generate fitting image
# ══════════════════════════════════════════════════════════════
@app.post("/api/generate")
async def api_generate(request: Request, payload: Dict = Body(...)):
client = get_openai_client()
current_user = get_current_user(request)
require_login(current_user)
product_images = payload.get("productImages", [])
product_image_filenames = payload.get("productImageFilenames", [])
sub_product_images = payload.get("subProductImages", [])
sub_product_image_filenames = payload.get("subProductImageFilenames", [])
sub_product_slots = payload.get("subProductSlots", [])
model_face = payload.get("modelFace")
model_face_filename = payload.get("modelFaceFilename")
product_infos = payload.get("productInfos", [])
shot_label = payload.get("shotLabel", "전신(정면)")
custom_prompt = payload.get("customPrompt", "")
sanitized_custom_prompt = _sanitize_custom_prompt_for_single_image(custom_prompt)
single_image_lock = _build_single_image_composition_lock()
if sanitized_custom_prompt != (custom_prompt or "").strip():
print(f"[GENERATE] Custom prompt sanitized for single-image output")
reference_image = payload.get("referenceImage")
background_image = payload.get("backgroundImage")
tone_reference_image = payload.get("toneReferenceImage")
image_size = _resolve_image_size(payload.get("imageSize"))
model_height_cm = _resolve_model_height(model_face, payload.get("modelHeight", 0))
model_gender = _resolve_model_gender(model_face, payload.get("modelGender"))
model_age_group = _resolve_model_age_group(model_face, payload.get("modelAgeGroup"))
model_kids_grade = _resolve_model_kids_grade(model_face, payload.get("modelKidsGrade"))
garment_length_guide = _build_garment_length_prompts(product_infos, model_height_cm)
has_reference = bool(
reference_image
and isinstance(reference_image, str)
and reference_image.startswith("data:")
)
has_background = bool(
background_image
and isinstance(background_image, str)
and background_image.startswith("data:")
)
request_started_at = datetime.datetime.now()
request_ts = request_started_at.strftime("%Y-%m-%d %H:%M:%S")
print(f"[GENERATE] [{request_ts}] User: {current_user['username']}{' (logged in)' if current_user['is_logged_in'] else ' (anon)'} | Shot: {shot_label} | Reference: {'YES' if has_reference else 'NO'} | Background: {'YES' if has_background else 'NO'} | ToneRef: {'YES' if tone_reference_image else 'NO'}")
wall_shadow_block = _build_wall_shadow_block()
# ──────────────────────────────────────────────────────────
# MODE A: TRANSFORM (reference exists → PICasso pattern)
# ──────────────────────────────────────────────────────────
if has_reference:
transform_instruction = _build_transform_instruction(shot_label, model_gender)
if not transform_instruction:
camera_angle = _build_camera_prompt(shot_label, model_gender, model_age_group, model_height_cm, model_kids_grade)
transform_instruction = (
f"Transform this full body fashion photo. Change the angle/framing to: {camera_angle}. "
f"Keep the EXACT same model, face, outfit, shoes, and soft light-gray studio background"
)
parts = []
parsed_ref = _parse_base64(reference_image)
ref_size_kb = len(parsed_ref["data"]) * 3 / 4 / 1024
print(f"[GENERATE] → TRANSFORM MODE | ref ~{ref_size_kb:.0f} KB")
parts.append({"inline_data": {"data": parsed_ref["data"], "mime_type": parsed_ref["mime_type"]}})
if has_background:
parsed_bg = _parse_base64(background_image)
bg_size_kb = len(parsed_bg["data"]) * 3 / 4 / 1024
print(f"[GENERATE] → Background ref ~{bg_size_kb:.0f} KB")
parts.append({"inline_data": {"data": parsed_bg["data"], "mime_type": parsed_bg["mime_type"]}})
user_tone_part, user_tone_palette_text = _parse_user_tone_reference(tone_reference_image) if tone_reference_image else (None, "")
has_user_tone_ref = bool(user_tone_palette_text)
if has_user_tone_ref:
print("[GENERATE] → User tone palette text attached (TRANSFORM, image omitted)")
transform_crop_box_lock = _build_transform_crop_box_lock(shot_label)
body_proportion_guide = _build_body_proportion_prompt(
shot_label, model_gender, model_age_group, model_face, model_height_cm, model_kids_grade
)
crop_template_guide = _build_crop_template_lock(shot_label, model_age_group)
background_constraint = (
"Use the LAST provided image as the target background plate. Keep that background's tone, floor line, perspective depth, and scene structure, and place the SAME model naturally into it. Change background only."
if has_background
else "Keep a soft light-gray studio background (subtle warm gray, NOT pure white). No heavy gradients, no props. A gentle tonal shift between wall and floor is acceptable for natural depth."
)
background_reference_note = (
"BACKGROUND REFERENCE (CRITICAL): The LAST image is the background plate. Use it as the target environment and preserve its visual layout."
if has_background else ""
)
tone_transform_note = _build_tone_reference_instruction(
mode="transform", palette_text=user_tone_palette_text
) if has_user_tone_ref else ""
accessory_transform_note = ""
_, has_sub_accessories_transform = _build_sub_accessory_prompt(sub_product_slots, sub_product_images, 0)
if has_sub_accessories_transform:
accessory_start = 2 + (1 if has_background else 0)
for img_b64 in sub_product_images:
if isinstance(img_b64, str) and img_b64.startswith("data:"):
parsed = _parse_base64(img_b64)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
accessory_transform_note = (
_build_sub_accessory_prompt(sub_product_slots, sub_product_images, accessory_start)[0]
+ "\n- While transforming angle/crop only, keep every listed accessory visible on the model — do NOT remove them."
)
print(f"[GENERATE] → Sub accessories attached (TRANSFORM): {len(sub_product_images)} image(s)")
prompt_text = f"""**IMAGE TRANSFORMATION TASK**:
{transform_instruction}
{transform_crop_box_lock}
{garment_length_guide}
{body_proportion_guide}
{crop_template_guide}
{background_reference_note}
{tone_transform_note}
{accessory_transform_note}
{_build_transform_face_lighting_override(shot_label)}
{_build_face_skin_lock_prompt(True)}
{wall_shadow_block}
{single_image_lock}
**STRICT CONSTRAINTS**:
1. **SOURCE TRUTH**: Use the provided Full Body image as the ONLY reference. Do NOT change the model identity, face, hair, or the garment design/color/fit. Hair identity must remain the same (length, part direction, volume, texture, and overall silhouette). Do NOT add new facial marks, under-eye blotches, cheek spots, freckles, stains, or asymmetric skin patches that are not clearly visible in the reference.
2. **BACKGROUND**: {background_constraint}
3. **{_build_transform_lighting_prompt(shot_label)}**
{_build_transform_face_lighting_override(shot_label)}
4. **QUALITY**: Maintain 8k resolution and photorealistic commercial fashion photography style.
5. **SHOES**: Keep the exact same shoes from the reference image.
6. **FABRIC REALISM**: The clothing must look like real worn fabric — show natural wrinkles at joints, gravity-driven drape, material-specific texture (knit stitch, denim whiskering, cotton creases), and subtle compression folds where the garment is pressed or pulled. Do NOT render fabric as flat or CG-smooth. {_FABRIC_REALISM_SUFFIX}
7. **BODY PROPORTIONS**: {"Maintain natural child body proportions." if model_age_group == "kids" else "Maintain commercial lookbook proportions (~8-head). Keep the natural waist near mid-frame. Do NOT shorten the torso or exaggerate leg length."}
{"8. **ADDITIONAL**: " + sanitized_custom_prompt if sanitized_custom_prompt else ""}"""
parts.append({"text": prompt_text})
# ──────────────────────────────────────────────────────────
# MODE B: ORIGINAL (no reference → full generation)
# ──────────────────────────────────────────────────────────
else:
parts = []
face_parts = _get_face_parts(model_face)
parts.extend(face_parts)
has_face = len(face_parts) > 0
user_tone_part, user_tone_palette_text = _parse_user_tone_reference(tone_reference_image) if tone_reference_image else (None, "")
has_tone_ref = user_tone_part is not None
if user_tone_part:
parts.append(user_tone_part)
print("[GENERATE] → User tone palette strip attached (ORIGINAL)")
for img_b64 in product_images:
if isinstance(img_b64, str) and img_b64.startswith("data:"):
parsed = _parse_base64(img_b64)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
product_image_count = sum(
1 for img_b64 in product_images
if isinstance(img_b64, str) and img_b64.startswith("data:")
)
accessory_image_start = 1 + (1 if has_face else 0) + (1 if has_tone_ref else 0) + product_image_count
sub_accessory_prompt, has_sub_accessories = _build_sub_accessory_prompt(
sub_product_slots, sub_product_images, accessory_image_start
)
if has_sub_accessories:
print(f"[GENERATE] → Sub accessories: {len(sub_product_images)} image(s), slots={sub_product_slots}")
for img_b64 in sub_product_images:
if isinstance(img_b64, str) and img_b64.startswith("data:"):
parsed = _parse_base64(img_b64)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
if has_background:
parsed_bg = _parse_base64(background_image)
bg_size_kb = len(parsed_bg["data"]) * 3 / 4 / 1024
print(f"[GENERATE] → Background ref ~{bg_size_kb:.0f} KB")
parts.append({"inline_data": {"data": parsed_bg["data"], "mime_type": parsed_bg["mime_type"]}})
outfit_details = "\n".join([
f"Clothing Item {i+1}: Category={info.get('category','')}, Fit={info.get('fit','')}, Length={info.get('length','')}"
+ (f", Total Length={info.get('totalLength','')}cm" if info.get('totalLength') else "")
for i, info in enumerate(product_infos)
])
# Dynamic outfit exclusion: prevent the model from hallucinating garments
# (most commonly a jacket/outer) that weren't actually provided. The words
# "hood" and "pocket" appear in our pose/hair rules and prime the model to
# imagine outerwear, so we explicitly negate that.
_CATEGORY_EN = {
"상의": "top (shirt / t-shirt / knit / sweater / blouse)",
"하의": "bottom (pants / trousers / jeans / shorts)",
"원피스": "dress (one-piece)",
"스커트": "skirt",
"아우터": "outer layer (jacket / coat / cardigan / hoodie / vest / blazer / anorak)",
}
provided_cats = {(info.get("category") or "").strip() for info in product_infos}
missing_cats = [_CATEGORY_EN[k] for k in _CATEGORY_EN if k not in provided_cats and k in _CATEGORY_EN]
if has_sub_accessories:
outfit_exclusion_rule = (
f"OUTFIT STRICTNESS (CRITICAL): The model MUST wear the {len(product_infos)} main garment(s) listed in OUTFIT → Details "
f"AND the accessories specified in ACCESSORIES below. "
f"Accessories are add-on items for Clothing Items 1–2 — they do NOT replace main garments and do NOT count as extra layers."
)
if missing_cats:
forbidden_list = "; ".join(missing_cats)
outfit_exclusion_rule += (
f"\n- DO NOT add forbidden main garments: {forbidden_list}."
f"\n- Specifically, if '아우터 (outer layer)' is forbidden, do not add a jacket/coat/cardigan/hoodie over the top."
)
outfit_exclusion_rule += (
"\n- DO NOT add any accessories beyond those shown in the accessory reference images."
"\n- The words 'hood' and 'pocket' in pose/hair rules describe hair or hand placement — NOT clothing."
)
elif missing_cats:
forbidden_list = "; ".join(missing_cats)
outfit_exclusion_rule = (
f"OUTFIT STRICTNESS (CRITICAL — READ CAREFULLY): The model MUST wear EXACTLY the {len(product_infos)} "
f"garment(s) listed above — no more, no less, nothing added on top.\n"
f"- DO NOT add any of the following to the outfit, under any circumstance: {forbidden_list}.\n"
f"- Specifically, if '아우터 (outer layer)' is in the forbidden list above, the topmost visible garment "
f"must be the top/knit/shirt itself. NEVER add a jacket, coat, cardigan, hoodie, vest, or blazer over it.\n"
f"- The words 'hood' and 'pocket' may appear in the pose or hair instructions below. Those words describe "
f"HAIR placement or HAND position ONLY — they are NOT a cue to add a hooded jacket or a garment with pockets. "
f"If the provided garments have no hood/pocket, the generated image must also have no hood/pocket.\n"
f"- Do NOT invent additional layers, accessories (scarf, belt, tie), or undergarments visible beyond what is listed."
)
else:
outfit_exclusion_rule = (
"OUTFIT STRICTNESS: The model wears EXACTLY the garments listed above, with no extra layers or accessories."
)
input_image_order_guide = _build_input_image_order_guide(
has_face, has_tone_ref, product_image_count,
sub_product_slots, sub_product_images, has_background,
)
camera_angle = _build_camera_prompt(shot_label, model_gender, model_age_group, model_height_cm, model_kids_grade)
body_proportion_guide = _build_body_proportion_prompt(
shot_label, model_gender, model_age_group, model_face, model_height_cm, model_kids_grade
)
crop_template_guide = _build_crop_template_lock(shot_label, model_age_group)
kids_height_lock = _build_kids_height_lock_prompt(model_kids_grade, model_height_cm) if model_age_group == "kids" else ""
face_instruction = (
"MODEL IDENTITY (CRITICAL): The model in this photo MUST be the EXACT same person "
"as shown in the face reference image. Copy the facial features precisely — "
"same face shape, eyes, nose, lips, eyebrows, skin tone, and hair.\n"
+ _hair_identity_lock(model_gender) + "\n"
+ _build_face_skin_lock_prompt(True)
) if has_face else "MODEL: The model should have an attractive, professional look."
# Tone reference guard — only emitted when the mosaic image is actually
# in the input list. Wording is deliberately blunt because the SDK
# otherwise tends to read additional images as faces or backgrounds.
tone_instruction = _build_tone_reference_instruction(
mode="original", has_face=has_face, palette_text=user_tone_palette_text
) if has_tone_ref else ""
background_setting = (
"SETTING (CRITICAL): Use the LAST provided image as the target background plate. Match that background's perspective, floor contact, depth, and global tone while preserving the model and outfit exactly. Background replacement must look naturally photographed, not composited."
if has_background
else "SETTING (CRITICAL): Soft light-gray studio background with a subtle warm gray tone — NOT pure white. A gentle, natural tonal transition between the wall and floor is acceptable to create depth and realism, similar to a real photography studio. No heavy gradients, no textures, no props."
)
background_reminder = (
"- The LAST provided image is the target background; replace environment only and keep subject identity/garment geometry unchanged."
if has_background else
"- The background must be a soft light-gray studio tone, NOT pure white."
)
prompt_text = f"""Act as a professional fashion photographer.
Task: Create a high-quality lifestyle/studio fashion photography image.
{face_instruction}
{tone_instruction}
{input_image_order_guide}
FRAMING AND COMPOSITION (MANDATORY): {camera_angle}
OUTFIT: The model is wearing a coordinated outfit consisting of the provided product images.
Details: {outfit_details}
{outfit_exclusion_rule}
{sub_accessory_prompt}
{garment_length_guide}
{body_proportion_guide}
{kids_height_lock}
{crop_template_guide}
FABRIC REALISM (CRITICAL): The clothing must look like REAL worn fabric, NOT flat CG or ironed-smooth textures.
- Add natural wrinkles and creases at joints (elbows, knees, waist, crotch) from body movement.
- Show gravity-driven drape and fabric weight — heavier fabrics hang with deeper folds, lighter fabrics flow softly.
- Render material-specific texture: knit should show visible yarn/stitch texture, denim should show subtle whiskering/fading at stress points, cotton should have soft organic creases, wool should have a fuzzy nap, silk/satin should show light-catching sheen with flowing drape.
- Include subtle compression wrinkles where the garment is pulled or pressed (e.g. waistband, pocket areas, where hands rest).
- The fabric should interact naturally with the body — not floating or stiff like a 3D render.
- {_FABRIC_REALISM_SUFFIX}
{_build_default_shoes_prompt(model_age_group)}
{background_setting}
{_build_even_studio_lighting_prompt("FULL-BODY")}
{_build_even_face_lighting_override("FULL-BODY")}
{_build_face_skin_lock_prompt(has_face)}
{wall_shadow_block}
{single_image_lock}
{"ADDITIONAL INSTRUCTIONS: " + sanitized_custom_prompt if sanitized_custom_prompt else ""}
IMPORTANT REMINDERS:
- Output ONE single photograph only. Never a grid, collage, or multi-panel layout.
- Strictly adhere to the FRAMING AND COMPOSITION instruction.
- OUTFIT: wear the main garments listed in OUTFIT → Details{" AND the accessories in ACCESSORIES — accessories MUST be visible" if has_sub_accessories else ""}. Do NOT add extra main garments (jacket/coat/cardigan/hood) unless listed in OUTFIT → Details.{" Do NOT add unlisted accessories." if not has_sub_accessories else " Do NOT add accessories beyond the reference images."} The words "hood" or "pocket" in pose/hair rules refer to hair or hand placement — NOT to clothing.
{background_reminder}
{"- The tone reference is for overall color grade and mood only — never copy its grid/pattern as the background." if has_tone_ref else ""}
{"- The model's face MUST match the reference face image exactly, with the same clean even skin tone." if has_face else ""}
{"- Follow the GARMENT LENGTH guide precisely." if garment_length_guide else ""}
{"- Maintain natural child body proportions." if model_age_group == "kids" and body_proportion_guide else ("- Maintain commercial lookbook body proportions with natural waist near mid-frame; do NOT use short-torso / ultra-long-leg distortion." if body_proportion_guide else "")}"""
parts.append({"text": prompt_text})
print(f"[GENERATE] → ORIGINAL MODE | products: {len(product_images)}, sub_accessories: {len(sub_product_images)}, face: {has_face}, tone_ref: {has_tone_ref}")
# ──────────────────────────────────────────────────────────
# Call OpenAI (gpt-image-2)
# ──────────────────────────────────────────────────────────
try:
print(f"[GENERATE] Calling OpenAI Images API ({OPENAI_IMAGE_MODEL})...")
# `_openai_image_generate` is sync (OpenAI SDK calls are blocking).
# Inside an async endpoint, calling it directly freezes the event loop
# and serializes all concurrent requests. Offloading to the default
# threadpool lets FastAPI actually serve requests in parallel.
image_b64 = await asyncio.to_thread(_openai_image_generate, client, parts, image_size)
print(f"[GENERATE] OpenAI API responded | image payload ~{len(image_b64)} chars")
# ── Push to HF Dataset IN BACKGROUND (non-blocking) ──
try:
now = datetime.datetime.now()
ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
elapsed_ms = int((now - request_started_at).total_seconds() * 1000)
raw_bytes = base64.b64decode(image_b64.split(";base64,", 1)[1]) if ";base64," in image_b64 else b""
face_b64_hub = None
face_filename_hub = None
if isinstance(model_face, str):
if model_face.startswith("preset_") and model_face in _FACE_PRESET_CACHE:
face_b64_hub = _FACE_PRESET_CACHE[model_face]["data"]
face_filename_hub = f"{model_face}.png"
elif model_face.startswith("data:") and ";base64," in model_face:
face_b64_hub = model_face.split(";base64,", 1)[1]
face_filename_hub = model_face_filename
# ⚡ Background push — response returns immediately
_push_to_hub_background(
record={
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
"app": "ai-model-lookbook", "shot_label": shot_label,
"user_username": current_user["username"],
"user_name": current_user["name"],
"user_email": current_user["email"],
"user_logged_in": current_user["is_logged_in"],
"product_infos": product_infos, "custom_prompt": custom_prompt or "(none)",
# 전체 엔지니어링 프롬프트도 저장(상세 통계에서 표시용). 길이 방어를 위해 8000자 컷.
"prompt": (prompt_text[:8000] if isinstance(prompt_text, str) else ""),
"model_face": model_face if isinstance(model_face, str) and model_face.startswith("preset_") else "user_upload" if model_face else "none",
"has_reference": has_reference,
"has_background": has_background,
"has_tone_reference": bool(tone_reference_image),
"mode": "TRANSFORM" if has_reference else "ORIGINAL",
"elapsed_ms": elapsed_ms,
"elapsed_sec": round(elapsed_ms / 1000, 3),
"product_image_filenames": product_image_filenames or [],
"sub_product_image_filenames": sub_product_image_filenames or [],
"model_face_filename": model_face_filename or face_filename_hub,
},
image_bytes=raw_bytes, ts=ts,
product_images_b64=product_images, face_b64=face_b64_hub,
product_filenames=product_image_filenames,
face_filename=face_filename_hub,
sub_product_images_b64=sub_product_images,
sub_product_filenames=sub_product_image_filenames,
)
except Exception as hub_err:
print(f"[HUB] Failed to start background push: {hub_err}")
elapsed_ms = int((datetime.datetime.now() - request_started_at).total_seconds() * 1000)
print(f"[GENERATE] ✅ Returning response for '{shot_label}' | request_ts={request_ts} | elapsed_ms={elapsed_ms}")
return {"imageUrl": image_b64}
except HTTPException:
raise
except Exception as e:
elapsed_ms = int((datetime.datetime.now() - request_started_at).total_seconds() * 1000)
print(f"[GENERATE] ❌ Failed after {elapsed_ms} ms")
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"이미지 생성 중 예기치 못한 오류가 발생했습니다: {str(e)[:200]}")
# ── API: Edit existing image ─────────────────────────────────
@app.post("/api/edit")
async def api_edit(request: Request, payload: Dict = Body(...)):
client = get_openai_client()
current_user = get_current_user(request)
require_login(current_user)
current_image = payload.get("currentImage", "")
edit_instruction = payload.get("editInstruction", "")
sanitized_edit_instruction = _sanitize_custom_prompt_for_single_image(edit_instruction)
single_image_lock = _build_single_image_composition_lock()
image_size = _resolve_image_size(payload.get("imageSize"))
reference_images = payload.get("referenceImages", [])
if not reference_images and payload.get("referenceImage"):
reference_images = [payload["referenceImage"]]
# Detect if the edit instruction is a background/scene change. When it is,
# the hardcoded "preserve background" clauses below would conflict with
# (and override) the user's intent — so we strip them for these requests.
# Signatures come from BG_INTEGRATION_BASE in the frontend.
_BG_CHANGE_SIGNATURES = (
"Re-shoot this EXACT model",
"MUST REBUILD",
"you MUST replace the background",
"SCENE MOOD HINT",
"SCENE SELECTION",
"CREATIVE DIRECTION",
)
is_background_change = any(sig in edit_instruction for sig in _BG_CHANGE_SIGNATURES)
if is_background_change:
print(f"[EDIT] Detected background-change intent — skipping 'preserve background' clauses")
parts = []
if current_image and current_image.startswith("data:"):
parsed = _parse_base64(current_image)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}, "preserve_format": True})
for ref in reference_images:
if ref and isinstance(ref, str) and ref.startswith("data:"):
parsed = _parse_base64(ref)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
if reference_images:
ref_count = sum(1 for r in reference_images if r and isinstance(r, str) and r.startswith("data:"))
if is_background_change:
# During background changes, references are for style/mood hints, not for locking bg.
ref_text = (
f"Use the following {ref_count} image(s) as visual reference(s) for style or mood hints only."
if ref_count > 1
else "Use the second image as a visual reference for style or mood hints only."
)
else:
ref_text = (
f"Use the following {ref_count} image(s) as visual reference(s) for the requested changes only. "
"Do NOT copy background, environment, or tone from the reference images — preserve the background from the first image."
) if ref_count > 1 else (
"Use the second image as a visual reference for the requested changes only. "
"Do NOT copy background or environment from the reference — preserve the background from the first image."
)
parts.append({"text": ref_text})
if is_background_change:
# Background changes: user's instruction IS the scene directive. Do NOT
# add any "preserve background" counter-instruction here.
parts.append({
"text": f"{sanitized_edit_instruction}\n\n"
f"{single_image_lock}\n\n"
f"LOCAL EDIT ONLY (ABSOLUTE): Apply only the requested change. Do NOT perform a full-image retouch, beauty retouch, relighting pass, color grading pass, tone matching pass, or global contrast/saturation/white-balance change.\n\n"
f"SUBJECT LIGHTING LOCK (CRITICAL): Preserve the first image's lighting on the person exactly. Face tone, skin brightness, shadow direction, highlight intensity, color temperature, and garment lighting must remain unchanged. The new background may be integrated with shadows/contact only, but must NOT relight or recolor the person.\n\n"
f"EDIT LOCALITY LOCK (CRITICAL): All unrequested regions from the first image must remain visually unchanged. Unless the instruction explicitly asks to edit face or skin, the face, neck, hands, legs, and all exposed skin are non-editable reference areas, not areas to regenerate.\n\n"
f"FACE / SKIN LOCK (CRITICAL): Preserve the first image's face exactly. Skin texture, facial lighting, natural highlights, pores, and skin tone gradients must remain unchanged. Do NOT smooth, repaint, mottle, blur, recolor, brighten, darken, or patchily relight the person's skin.\n\n"
f"MODEL RATIO LOCK (CRITICAL): Preserve the first image's body proportions exactly. Do NOT change apparent height, head size, shoulder width, torso length, waist/hip/knee position, leg length, ankle position, or foot scale. Background or pose changes must not alter the model's body scale.\n\n"
f"The output must be a high-quality fashion photograph. Keep the original subject pixels visually stable while applying the requested change."
})
else:
parts.append({
"text": f"Modify this fashion photo based on the following instruction: {sanitized_edit_instruction}. "
f"{single_image_lock} "
f"LOCAL EDIT ONLY (ABSOLUTE): Apply only the requested change. Do NOT perform a full-image retouch, beauty retouch, relighting pass, color grading pass, tone matching pass, or global contrast/saturation/white-balance change. "
f"SUBJECT LIGHTING LOCK (CRITICAL): Preserve the first image's lighting on the person exactly. Face tone, skin brightness, shadow direction, highlight intensity, color temperature, and garment lighting must remain unchanged. "
f"EDIT LOCALITY LOCK (CRITICAL): All unrequested regions from the first image must remain visually unchanged. Unless the instruction explicitly asks to edit face or skin, the face, neck, hands, legs, and all exposed skin are non-editable reference areas, not areas to regenerate. "
f"Maintain the same model and outfit with high fidelity, and preserve composition unless the instruction explicitly requests a framing change. "
f"MODEL RATIO LOCK (CRITICAL): Preserve the first image's body proportions exactly. Do NOT change apparent height, head size, shoulder width, torso length, waist/hip/knee position, leg length, ankle position, or foot scale. "
f"FACE / SKIN LOCK (CRITICAL): Preserve the first image's face exactly. Skin texture, facial lighting, natural highlights, pores, and skin tone gradients must remain unchanged. Do NOT smooth, repaint, mottle, blur, recolor, brighten, darken, or patchily relight the person's skin. "
f"BACKGROUND (CRITICAL): Keep the exact same background — tone, color, and environment — as in the first image. "
f"Do NOT change the background unless the instruction explicitly requests a background change. "
f"The output must be a high-quality fashion photograph. Keep all non-requested pixels visually stable."
})
try:
image_b64 = await asyncio.to_thread(_openai_image_generate, client, parts, image_size)
# Background push for edit too
try:
now = datetime.datetime.now()
ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
raw_bytes = base64.b64decode(image_b64.split(";base64,", 1)[1]) if ";base64," in image_b64 else b""
_push_to_hub_background(
record={
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
"app": "ai-model-lookbook", "type": "edited",
"user_username": current_user["username"],
"user_name": current_user["name"],
"user_email": current_user["email"],
"user_logged_in": current_user["is_logged_in"],
"edit_instruction": edit_instruction,
"is_background_change": is_background_change,
},
image_bytes=raw_bytes, ts=ts,
)
except Exception:
pass
return {"imageUrl": image_b64}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"이미지 편집 중 예기치 못한 오류가 발생했습니다: {str(e)[:200]}")
# ══════════════════════════════════════════════════════════════
# Comfy Cloud 브리지 (SPAO 워크플로우) — 제출→폴링 구조, comfy_routes.py 참조
# ══════════════════════════════════════════════════════════════
try:
from comfy_routes import build_comfy_router
app.include_router(build_comfy_router(get_current_user, require_login, _FACE_PRESET_CACHE, _push_to_hub_background))
print("[COMFY] routes mounted: /api/comfy/generate, /api/comfy/jobs/{id}, /api/comfy/health")
except Exception as _comfy_err:
traceback.print_exc()
print(f"[COMFY] failed to mount routes: {_comfy_err}")
|