@officexapp/vidfarm-devcli 0.21.26 → 0.21.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -591,6 +591,7 @@
591
591
  background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-full);padding:4px 11px;text-decoration:none;line-height:1.4;
592
592
  transition:background var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
593
593
  .rk-aichat-preview-act:hover{background:var(--rk-gold-tint);border-color:var(--rk-gold-600)}
594
+ .rk-aichat-preview-act[hidden]{display:none}
594
595
 
595
596
  /* ── markdown pipe tables (assistant messages + .md previews) ── */
596
597
  .rk-md-tablewrap{max-width:100%;overflow-x:auto;margin:8px 0;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg)}
@@ -622,6 +623,14 @@
622
623
  .rk-aichat-file-thumb--markdown,.rk-aichat-file-thumb--text{background:var(--rk-n-50)}.rk-aichat-file-thumb--audio{background:var(--rk-gold-tint2)}
623
624
  .rk-aichat-file-play{position:absolute;inset:0;display:grid;place-items:center;font-size:22px;color:#fff;text-shadow:0 1px 6px rgba(0,0,0,.55);pointer-events:none}
624
625
  .rk-aichat-file-name{font-size:11px;font-weight:600;color:var(--rk-n-600);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:112px}
626
+ /* "Edit in the tool" pencil — only on image/video cards; opens /tools/image or
627
+ /tools/video in a new tab with this file preloaded as the working file. */
628
+ .rk-aichat-file-open{position:absolute;top:5px;right:5px;width:24px;height:24px;border-radius:var(--rk-r-md);
629
+ display:grid;place-items:center;background:rgba(255,255,255,.94);border:1px solid var(--rk-border);color:var(--rk-ink);
630
+ opacity:0;transition:opacity var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease);z-index:2}
631
+ .rk-aichat-file-open svg{width:13px;height:13px}
632
+ .rk-aichat-file:hover .rk-aichat-file-open,.rk-aichat-file-open:focus-visible{opacity:1}
633
+ .rk-aichat-file-open:hover{background:var(--rk-gold-tint)}
625
634
 
626
635
  /* create_video pipeline: live status row + clickable result links */
627
636
  .rk-aichat-flow{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--rk-text-muted);padding:2px 2px}
@@ -1086,13 +1095,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1086
1095
  .rk-ip-tab svg{flex:none}
1087
1096
 
1088
1097
  .rk-inpaint-page{padding-left:var(--rk-gutter);padding-right:var(--rk-gutter)}
1089
- .rk-inpaint-workbench{display:grid;grid-template-columns:320px minmax(0,1fr) 320px;gap:20px;align-items:start}
1090
- @media(max-width:1200px){.rk-inpaint-workbench{grid-template-columns:320px minmax(0,1fr)}.rk-inpaint-output{grid-column:1 / -1;grid-row:2}}
1091
- @media(max-width:820px){.rk-inpaint-workbench{grid-template-columns:1fr}.rk-inpaint-output,.rk-inpaint-stage,.rk-inpaint-panel{grid-column:auto}.rk-inpaint-stage{order:-1}}
1098
+ .rk-inpaint-workbench{display:grid;grid-template-columns:300px minmax(0,1fr) 300px;gap:20px;align-items:start}
1099
+ @media(max-width:1200px){.rk-inpaint-workbench{grid-template-columns:300px minmax(0,1fr)}.rk-inpaint-output{grid-column:1 / -1;grid-row:2}}
1100
+ @media(max-width:820px){.rk-inpaint-workbench{grid-template-columns:1fr}.rk-inpaint-output,.rk-ip-chat,.rk-inpaint-panel{grid-column:auto}.rk-ip-chat{order:-1}}
1092
1101
 
1093
1102
  /* panels (no side borders — uniform hairline) */
1094
- .rk-inpaint-panel,.rk-inpaint-stage,.rk-inpaint-output{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card)}
1103
+ .rk-inpaint-panel,.rk-ip-chat,.rk-inpaint-output{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card)}
1095
1104
  .rk-inpaint-panel{padding:18px;display:grid;gap:16px;align-content:start}
1105
+ /* fields the chat drives programmatically — present in the DOM, never shown */
1106
+ .rk-ip-legacy{display:none!important}
1107
+ .rk-ip-maskblock[hidden]{display:none}
1096
1108
  .rk-inpaint-block{display:grid;gap:9px}
1097
1109
  .rk-inpaint-h2{font-family:var(--rk-font-display);font-size:var(--rk-text-lg);font-weight:800;color:var(--rk-ink);letter-spacing:-.01em}
1098
1110
 
@@ -1173,40 +1185,66 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1173
1185
  .rk-inpaint-workbench.is-locked .rk-inpaint-generate{opacity:1;cursor:default;background:var(--rk-n-50);color:var(--rk-text-muted);border-color:var(--rk-border);box-shadow:none;font-size:12.5px}
1174
1186
  .rk-inpaint-workbench.is-locked .rk-inpaint-generate svg{display:none}
1175
1187
 
1176
- /* center stage + canvas */
1177
- .rk-inpaint-stage{min-height:420px;display:grid;place-items:center;padding:20px;overflow:hidden}
1178
- .rk-inpaint-empty{display:grid;justify-items:center;gap:8px;text-align:center;color:var(--rk-text-muted);padding:34px 20px}
1179
- .rk-inpaint-empty[hidden]{display:none}
1180
- .rk-inpaint-empty-ic{color:var(--rk-n-400)}
1181
- .rk-inpaint-empty-title{font-family:var(--rk-font-display);font-weight:800;font-size:var(--rk-text-lg);color:var(--rk-ink)}
1182
- .rk-inpaint-empty-sub{font-size:13px}
1183
- .editor-inpaint-canvas-wrap{position:relative;display:inline-block;line-height:0;max-width:100%;border-radius:var(--rk-r-2xl);overflow:hidden;box-shadow:var(--rk-shadow-md);
1184
- --inpaint-brush-cursor-size:70px;--inpaint-brush-cursor-x:0px;--inpaint-brush-cursor-y:0px}
1185
- .editor-inpaint-canvas-wrap[hidden]{display:none}
1186
- .editor-inpaint-canvas-wrap img{display:block;max-width:100%;max-height:min(64vh,620px);width:auto;height:auto;object-fit:contain;user-select:none}
1187
- .editor-inpaint-canvas-wrap canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.86;cursor:none;touch-action:none}
1188
- .editor-inpaint-brush-cursor{position:absolute;z-index:2;left:0;top:0;width:var(--inpaint-brush-cursor-size);height:var(--inpaint-brush-cursor-size);
1189
- border:2px solid rgba(255,255,255,.95);border-radius:50%;box-shadow:0 0 0 1px rgba(28,25,23,.7),0 0 18px rgba(28,25,23,.25);opacity:0;pointer-events:none;
1190
- transform:translate(var(--inpaint-brush-cursor-x),var(--inpaint-brush-cursor-y)) translate(-50%,-50%)}
1191
- .editor-inpaint-brush-cursor[data-visible="true"]{opacity:1}
1192
1188
 
1193
- /* text-to-image composer (empty state) */
1194
- .rk-ip-gen{width:100%;max-width:460px;margin-top:16px;display:grid;gap:10px;justify-items:stretch}
1195
- .rk-ip-gen-or{position:relative;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--rk-text-faint)}
1196
- .rk-ip-gen-or::before,.rk-ip-gen-or::after{content:"";position:absolute;top:50%;width:calc(50% - 26px);height:1px;background:var(--rk-border)}
1197
- .rk-ip-gen-or::before{left:0}.rk-ip-gen-or::after{right:0}
1198
- .rk-ip-gen-box{background:#fff;border:1.5px solid var(--rk-border-strong);border-radius:var(--rk-r-2xl);padding:10px 10px 8px;display:grid;gap:8px;
1199
- box-shadow:var(--rk-shadow-sm);transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease);text-align:left}
1200
- .rk-ip-gen-box:focus-within{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1201
- .rk-ip-gen.is-dragref .rk-ip-gen-box{border-color:var(--rk-gold-600);border-style:dashed;background:var(--rk-gold-tint)}
1202
- .rk-ip-gen.is-busy .rk-ip-gen-box{opacity:.75}
1203
- .rk-ip-gen-input{width:100%;border:0;outline:0;resize:none;background:transparent;font:inherit;font-size:14px;line-height:1.45;color:var(--rk-ink);min-height:44px;padding:2px 4px}
1204
- .rk-ip-gen-input::placeholder{color:var(--rk-text-faint)}
1205
- .rk-ip-gen-input:disabled{cursor:not-allowed;color:var(--rk-text-muted)}
1206
- .rk-ip-gen-clip:disabled{opacity:.5;cursor:not-allowed}
1207
- .rk-ip-gen-presets{display:flex;flex-wrap:wrap;gap:6px}
1208
- .rk-ip-preset{flex:none;border:1px dashed var(--rk-border-strong,var(--rk-border));background:var(--rk-n-50);color:var(--rk-n-700);border-radius:999px;padding:4px 10px;font-size:11.5px;font-weight:600;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1209
- .rk-ip-preset:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1189
+ /* ---- shared chat surface (tools/image + tools/video) ---------------------- */
1190
+ .rk-ip-chat{display:grid;grid-template-rows:minmax(0,1fr) auto;height:min(76vh,860px);min-height:520px;overflow:hidden}
1191
+ .rk-ip-feed{min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:18px 18px 6px;display:flex;flex-direction:column;gap:14px}
1192
+ .rk-ip-canvasholder{display:none}
1193
+ .rk-ip-turn{display:flex;flex-direction:column;gap:6px;max-width:100%}
1194
+ .rk-ip-turn[data-role="user"]{align-items:flex-end}
1195
+ .rk-ip-turn[data-role="assistant"]{align-items:flex-start}
1196
+ .rk-ip-bubble{max-width:min(88%,640px);padding:10px 13px;border-radius:var(--rk-r-2xl);font-size:13.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
1197
+ .rk-ip-turn[data-role="user"] .rk-ip-bubble{background:var(--rk-gold-tint);border:1px solid var(--rk-gold-600);color:var(--rk-ink);border-bottom-right-radius:var(--rk-r-md,8px)}
1198
+ .rk-ip-turn[data-role="assistant"] .rk-ip-bubble{background:var(--rk-n-50);border:1px solid var(--rk-border);color:var(--rk-text-muted);border-bottom-left-radius:var(--rk-r-md,8px)}
1199
+ .rk-ip-turn-tag{font-size:10.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--rk-text-faint)}
1200
+ .rk-ip-turn-refs{display:flex;flex-wrap:wrap;gap:5px;justify-content:flex-end}
1201
+ .rk-ip-turn-refs img{width:34px;height:34px;border-radius:var(--rk-r-md,8px);border:1px solid var(--rk-border);object-fit:cover}
1202
+ .rk-ip-media{width:fit-content;max-width:min(100%,640px);display:grid;gap:7px}
1203
+ .rk-ip-media-frame{position:relative;width:fit-content;max-width:100%;border-radius:var(--rk-r-2xl);overflow:hidden;background:var(--rk-n-100);border:1px solid var(--rk-border);line-height:0}
1204
+ .rk-ip-media-frame.is-latest{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1205
+ .rk-ip-media-frame img,.rk-ip-media-frame video{display:block;max-width:100%;max-height:min(52vh,520px);width:auto;height:auto}
1206
+ .rk-ip-media-actions{display:flex;flex-wrap:wrap;align-items:center;gap:5px}
1207
+ .rk-ip-act{display:inline-grid;place-items:center;width:27px;height:27px;padding:0;border:1px solid var(--rk-border);border-radius:50%;background:#fff;color:var(--rk-n-700);cursor:pointer;text-decoration:none;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1208
+ .rk-ip-act:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1209
+ .rk-ip-act-note{font-size:11.5px;font-weight:700;color:var(--rk-text-faint);margin-left:2px}
1210
+ .rk-ip-pending{display:flex;align-items:center;gap:9px;padding:10px 13px;border:1px solid var(--rk-border);border-radius:var(--rk-r-2xl);background:var(--rk-n-50);font-size:12.5px;font-weight:700;color:var(--rk-text-muted)}
1211
+ .rk-ip-pending::before{content:"";flex:none;width:16px;height:16px;border-radius:50%;border:2.5px solid var(--rk-gold-tint2,rgba(252,185,0,.35));border-top-color:var(--rk-gold-700);animation:rk-ip-spin .8s linear infinite}
1212
+ .rk-ip-turn.is-flash .rk-ip-bubble,.rk-ip-turn.is-flash .rk-ip-media-frame{box-shadow:0 0 0 3px var(--rk-gold-600)}
1213
+ .rk-ip-turn[data-kind="stopped"] .rk-ip-bubble{background:var(--rk-n-100);border-color:var(--rk-border-strong);color:var(--rk-text-muted);font-weight:700}
1214
+ .rk-ip-turn[data-kind="error"] .rk-ip-bubble{background:#fdf3f2;border-color:#e6a8a0;color:#b42318;font-weight:600}
1215
+
1216
+ /* ---- composer ------------------------------------------------------------ */
1217
+ .rk-ip-composer{flex:none;margin:0;padding:12px 18px 16px;border-top:1px solid var(--rk-border);background:var(--rk-surface);display:grid;gap:8px;
1218
+ border-bottom-left-radius:var(--rk-r-3xl);border-bottom-right-radius:var(--rk-r-3xl)}
1219
+ .rk-ip-composer.is-dragref{background:var(--rk-gold-tint)}
1220
+ .rk-ip-composer.is-busy .rk-ip-comp-status{color:var(--rk-gold-700)}
1221
+ /* The grip is anchored to this wrapper, not the textarea, so it never scrolls
1222
+ away with the textarea's own overflow once the message passes 10 rows. */
1223
+ .rk-ip-comp-field{position:relative;display:block}
1224
+ .rk-ip-comp-input{display:block;width:100%;border:1.5px solid var(--rk-border-strong);border-radius:var(--rk-r-2xl);background:#fff;outline:0;resize:none;font:inherit;font-size:14px;line-height:1.45;
1225
+ color:var(--rk-ink);min-height:56px;overflow-y:hidden;padding:10px 12px;transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
1226
+ .rk-ip-comp-input:focus{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1227
+ .rk-ip-comp-input::placeholder{color:var(--rk-text-faint)}
1228
+ .rk-ip-comp-input:disabled{cursor:not-allowed;color:var(--rk-text-muted);background:var(--rk-n-50)}
1229
+ .rk-ip-comp-grip{position:absolute;right:5px;bottom:5px;width:16px;height:16px;display:grid;place-items:center;color:var(--rk-n-400);cursor:ns-resize;touch-action:none;
1230
+ border-radius:4px;opacity:.75;transition:color var(--rk-dur) var(--rk-ease),opacity var(--rk-dur) var(--rk-ease)}
1231
+ .rk-ip-comp-grip:hover,.rk-ip-comp-field.is-resizing .rk-ip-comp-grip{color:var(--rk-gold-700);opacity:1}
1232
+ .rk-ip-comp-field.is-resizing{user-select:none}
1233
+ .rk-ip-comp-bar{display:flex;align-items:center;gap:8px}
1234
+ .rk-ip-comp-modeselect{appearance:none;-webkit-appearance:none;border:1px solid var(--rk-border-strong);border-radius:999px;background:var(--rk-n-50);color:var(--rk-ink);
1235
+ font:inherit;font-size:12px;font-weight:800;padding:7px 26px 7px 12px;cursor:pointer;outline:0;
1236
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 20 20' fill='none' stroke='%2378716c' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E");
1237
+ background-repeat:no-repeat;background-position:right 9px center}
1238
+ .rk-ip-comp-modeselect:focus{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1239
+ .rk-ip-comp-status{flex:1;min-width:0;margin:0;font-size:11.5px;font-weight:600;line-height:1.35;color:var(--rk-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1240
+ .rk-ip-comp-status[data-tone="error"]{color:#b42318}
1241
+ .rk-ip-comp-status[data-tone="success"]{color:#1f7a44}
1242
+ .rk-ip-comp-mask{display:flex;align-items:center;gap:8px;padding:6px 8px 6px 11px;border:1px solid var(--rk-gold-600);border-radius:999px;background:var(--rk-gold-tint);
1243
+ font-size:11.5px;font-weight:700;color:var(--rk-gold-700);justify-self:start;max-width:100%}
1244
+ .rk-ip-comp-mask[hidden]{display:none}
1245
+ .rk-ip-comp-mask button{border:0;background:transparent;color:inherit;font:inherit;font-size:11.5px;font-weight:800;text-decoration:underline;cursor:pointer;padding:0}
1246
+
1247
+ /* ---- attachment chips ---------------------------------------------------- */
1210
1248
  .rk-ip-gen-refs{display:flex;flex-wrap:wrap;gap:6px}
1211
1249
  .rk-ip-gen-refs[hidden]{display:none}
1212
1250
  .rk-ip-gen-ref{display:inline-flex;align-items:center;gap:6px;padding:3px 4px 3px 6px;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);background:var(--rk-n-50);font-size:11.5px;color:var(--rk-n-700);max-width:180px}
@@ -1214,23 +1252,25 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1214
1252
  .rk-ip-gen-ref span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1215
1253
  .rk-ip-gen-ref button{flex:none;border:0;background:transparent;cursor:pointer;color:var(--rk-text-faint);font-size:15px;line-height:1;padding:0 2px}
1216
1254
  .rk-ip-gen-ref button:hover{color:var(--rk-ink)}
1217
- .rk-ip-gen-bar{display:flex;align-items:center;gap:8px}
1218
- .rk-ip-gen-hint{flex:1;font-size:11.5px;color:var(--rk-text-faint);line-height:1.35}
1255
+ .rk-ip-gen-ref.is-pending{opacity:.6}
1219
1256
  .rk-ip-gen-clip,.rk-ip-gen-send{flex:none;display:grid;place-items:center;width:34px;height:34px;border-radius:50%;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),opacity var(--rk-dur) var(--rk-ease)}
1220
1257
  .rk-ip-gen-clip{border:1px solid var(--rk-border);background:#fff;color:var(--rk-n-600)}
1221
1258
  .rk-ip-gen-clip:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1259
+ .rk-ip-gen-clip:disabled{opacity:.5;cursor:not-allowed}
1222
1260
  .rk-ip-gen-send{border:0;background:var(--rk-gold-500,var(--rk-gold-600));color:var(--rk-ink)}
1223
1261
  .rk-ip-gen-send:hover:not(:disabled){background:var(--rk-gold-600)}
1224
1262
  .rk-ip-gen-send:disabled{opacity:.5;cursor:default}
1225
- .rk-ip-gen-send.is-busy{background:var(--rk-gold-tint);pointer-events:none}
1226
- .rk-ip-gen-spin,.rk-ip-gen-send .rk-ip-gen-spin{width:16px;height:16px;border-radius:50%;border:2.5px solid var(--rk-gold-tint2,rgba(252,185,0,.35));border-top-color:var(--rk-gold-700);animation:rk-ip-spin .8s linear infinite}
1263
+ .rk-ip-gen-send[data-mode="stop"]{background:var(--rk-ink);color:#fff}
1227
1264
  .rk-ip-gen-status{font-size:12px;font-weight:600;min-height:16px;color:var(--rk-text-muted);text-align:left;padding-left:2px}
1265
+ .rk-ip-gen-status[hidden]{display:none}
1228
1266
  .rk-ip-gen-status[data-tone="error"]{color:#b42318}
1229
1267
  .rk-ip-gen-status[data-tone="success"]{color:#1f7a44}
1268
+ .rk-ip-gen-presets{display:flex;flex-wrap:wrap;gap:6px}
1269
+ .rk-ip-preset{flex:none;border:1px dashed var(--rk-border-strong,var(--rk-border));background:var(--rk-n-50);color:var(--rk-n-700);border-radius:999px;padding:4px 10px;font-size:11.5px;font-weight:600;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1270
+ .rk-ip-preset:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1230
1271
 
1231
- /* file-directory pop panel (reference picker) */
1272
+ /* ---- attach pop panel (shared file explorer) ----------------------------- */
1232
1273
  .rk-ip-filemodal{position:fixed;inset:0;z-index:60;background:rgba(28,25,23,.44);backdrop-filter:blur(2px);display:grid;place-items:center;padding:20px;animation:rk-ip-fade .16s var(--rk-ease)}
1233
- @keyframes rk-ip-fade{from{opacity:0}to{opacity:1}}
1234
1274
  .rk-ip-filemodal-panel{width:min(560px,100%);max-height:min(80vh,680px);display:flex;flex-direction:column;background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-lg,var(--rk-shadow-card));overflow:hidden}
1235
1275
  .rk-ip-filemodal-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 18px;border-bottom:1px solid var(--rk-border)}
1236
1276
  .rk-ip-filemodal-title{font-family:var(--rk-font-display);font-weight:800;font-size:var(--rk-text-md,15px);color:var(--rk-ink)}
@@ -1241,6 +1281,37 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1241
1281
  .rk-ip-filemodal-close{border:0;background:transparent;font-size:22px;line-height:1;color:var(--rk-text-muted);cursor:pointer;padding:0 4px}
1242
1282
  .rk-ip-filemodal-close:hover{color:var(--rk-ink)}
1243
1283
  .rk-ip-filemodal-mount{padding:12px 14px;overflow:auto;flex:1;min-height:0}
1284
+ @keyframes rk-ip-spin{to{transform:rotate(360deg)}}
1285
+ @keyframes rk-ip-fade{from{opacity:0}to{opacity:1}}
1286
+ @media(max-width:820px){.rk-ip-chat{height:auto;min-height:0}.rk-ip-feed{max-height:62vh}}
1287
+
1288
+ .rk-inpaint-empty{display:grid;justify-items:center;gap:8px;text-align:center;color:var(--rk-text-muted);padding:34px 20px;margin:auto 0}
1289
+ .rk-inpaint-empty[hidden]{display:none}
1290
+ .rk-inpaint-empty-ic{color:var(--rk-n-400)}
1291
+ .rk-inpaint-empty-title{font-family:var(--rk-font-display);font-weight:800;font-size:var(--rk-text-lg);color:var(--rk-ink)}
1292
+ .rk-inpaint-empty-sub{font-size:13px}
1293
+ .editor-inpaint-canvas-wrap{position:relative;display:inline-block;line-height:0;max-width:100%;border-radius:var(--rk-r-2xl);overflow:hidden;box-shadow:var(--rk-shadow-md);
1294
+ --inpaint-brush-cursor-size:70px;--inpaint-brush-cursor-x:0px;--inpaint-brush-cursor-y:0px}
1295
+ .editor-inpaint-canvas-wrap[hidden]{display:none}
1296
+ .editor-inpaint-canvas-wrap img{display:block;max-width:100%;max-height:min(52vh,520px);width:auto;height:auto;object-fit:contain;user-select:none}
1297
+ /* paint toolbar — sits directly under the image it paints, never over it */
1298
+ /* align-items:center keeps the canvas wrap at the image's intrinsic width — stretching
1299
+ it would stretch the absolutely-positioned mask canvas and skew stroke coordinates. */
1300
+ .rk-ip-paintstack{display:inline-flex;flex-direction:column;align-items:center;max-width:100%}
1301
+ .rk-ip-painttools{width:100%;box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:6px;padding:6px 8px;border-top:1px solid var(--rk-border);background:var(--rk-n-50);line-height:1;flex-wrap:wrap}
1302
+ .rk-ip-painttools-brush{display:flex;align-items:center;gap:6px}
1303
+ .rk-ip-painttools-label{font-size:11px;font-weight:800;color:var(--rk-text-muted);white-space:nowrap}
1304
+ .rk-ip-painttools .rk-inpaint-range{width:86px}
1305
+ .rk-ip-painttool{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;border:1px solid var(--rk-border);background:#fff;color:var(--rk-n-700);cursor:pointer}
1306
+ .rk-ip-painttool:hover:not(:disabled){background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1307
+ .rk-ip-painttool:disabled{opacity:.45;cursor:default}
1308
+ .rk-ip-painttool svg{width:13px;height:13px}
1309
+ .editor-inpaint-canvas-wrap canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.86;cursor:none;touch-action:none}
1310
+ .editor-inpaint-brush-cursor{position:absolute;z-index:2;left:0;top:0;width:var(--inpaint-brush-cursor-size);height:var(--inpaint-brush-cursor-size);
1311
+ border:2px solid rgba(255,255,255,.95);border-radius:50%;box-shadow:0 0 0 1px rgba(28,25,23,.7),0 0 18px rgba(28,25,23,.25);opacity:0;pointer-events:none;
1312
+ transform:translate(var(--inpaint-brush-cursor-x),var(--inpaint-brush-cursor-y)) translate(-50%,-50%)}
1313
+ .editor-inpaint-brush-cursor[data-visible="true"]{opacity:1}
1314
+
1244
1315
 
1245
1316
  /* right: result + history */
1246
1317
  .rk-inpaint-output{padding:18px;display:grid;gap:18px;align-content:start}
@@ -1256,6 +1327,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1256
1327
  .custom-inpaint-history-item[data-kind="source"]{background:var(--rk-gold-tint);border-color:var(--rk-gold-600)}
1257
1328
  .custom-inpaint-history-thumb-button{width:54px;height:54px;display:grid;padding:0;border:0;border-radius:var(--rk-r-lg);background:transparent;cursor:zoom-in}
1258
1329
  .custom-inpaint-history-thumb{width:54px;height:54px;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);object-fit:cover;background:var(--rk-n-200)}
1330
+ .custom-inpaint-history-videothumb{display:grid;place-items:center;font-size:16px;color:var(--rk-n-600);background:var(--rk-n-100)}
1331
+ .custom-inpaint-history-thumb-button{cursor:pointer}
1259
1332
  .custom-inpaint-history-generating{width:54px;height:54px;display:grid;place-items:center;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);background:var(--rk-n-100)}
1260
1333
  .custom-inpaint-history-generating::before{content:"";width:22px;height:22px;border-radius:50%;border:2.5px solid var(--rk-gold-tint2,rgba(252,185,0,.35));border-top-color:var(--rk-gold-700);animation:rk-ip-spin .8s linear infinite}
1261
1334
  .custom-inpaint-history-meta{min-width:0;display:grid;gap:5px}
@@ -1283,7 +1356,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1283
1356
  .custom-inpaint-lightbox-title{min-width:0;margin:0;color:var(--rk-ink);font-size:14px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1284
1357
  .custom-inpaint-lightbox-actions{display:flex;align-items:center;gap:8px}
1285
1358
  .custom-inpaint-lightbox-stage{min-height:0;display:grid;place-items:center;overflow:auto}
1286
- .custom-inpaint-lightbox-stage img{max-width:100%;max-height:calc(94svh - 92px);object-fit:contain;border-radius:var(--rk-r-xl);background:var(--rk-n-100)}
1359
+ .custom-inpaint-lightbox-stage img,.custom-inpaint-lightbox-stage video{max-width:100%;max-height:calc(94svh - 92px);object-fit:contain;border-radius:var(--rk-r-xl);background:var(--rk-n-100)}
1360
+ .custom-inpaint-lightbox-stage img[hidden],.custom-inpaint-lightbox-stage video[hidden]{display:none}
1287
1361
 
1288
1362
  @keyframes rk-ip-spin{to{transform:rotate(360deg)}}
1289
1363
 
@@ -1394,17 +1468,18 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1394
1468
  <div class="rk-inpaint-head-text">
1395
1469
  <div class="rk-ip-tabs" role="tablist" aria-label="Editor mode"><a class="rk-ip-tab is-active" href="/tools/image" role="tab" aria-selected="true"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 4.5-4.5 3 3L16 11l4 4"/></svg><span>Image</span></a><a class="rk-ip-tab" href="/tools/video" role="tab" aria-selected="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 9 4-2v10l-4-2"/></svg><span>Video</span></a><a class="rk-ip-tab" href="/tools/clipper" role="tab" aria-selected="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M8.1 8.1 20 18M8.1 15.9 20 6"/></svg><span>Clipper</span></a></div>
1396
1470
  <h1 class="rk-h1">Create Image</h1>
1397
- <p class="rk-sub">Upload an image (or generate one), paint mask regions in different colors, give each color its own instruction, add an optional whole-image prompt, and generate everything outside your mask stays untouched.</p>
1471
+ <p class="rk-sub">Chat to create an image, then keep chatting to refine it every message edits the newest image, and only that image, so instructions stay sharp. Paint a mask on it to change only that region. To animate an image, take it to the Video tool.</p>
1398
1472
  </div>
1399
1473
  <div class="rk-inpaint-toolbar">
1400
1474
  <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm rk-inpaint-startnew" data-custom-inpaint-start-new><svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4v12"/><path d="M4 10h12"/></svg> Start New</button>
1475
+ <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-copy-chat aria-label="Copy chat ID" title="Copy chat ID"><svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1"/><path d="M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2"/></svg></button>
1401
1476
  <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-settings-button aria-label="Settings" title="Settings"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8.8 3.4 9.4 2h1.2l.6 1.4 1.2.5 1.4-.6.9.9-.6 1.4.5 1.2 1.4.6v1.2l-1.4.6-.5 1.2.6 1.4-.9.9-1.4-.6-1.2.5-.6 1.4H9.4l-.6-1.4-1.2-.5-1.4.6-.9-.9.6-1.4-.5-1.2L4 8.6V7.4l1.4-.6.5-1.2-.6-1.4.9-.9 1.4.6 1.2-.5Z"/><circle cx="10" cy="8" r="2.4"/></svg></button>
1402
1477
  <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-history-toggle aria-label="Past sessions" title="Past sessions"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3.2 10a6.8 6.8 0 1 1 2 4.8"/><path d="M3.2 14.4v-3.6h3.6"/><path d="M10 6.2V10l2.6 1.6"/></svg></button>
1403
1478
  </div>
1404
1479
  </div>
1405
1480
 
1406
1481
  <div class="rk-inpaint-workbench">
1407
- <!-- LEFT: source + prompts + brush/color controls -->
1482
+ <!-- LEFT: source + per-color mask instructions (mask block appears once you paint) -->
1408
1483
  <section class="rk-inpaint-panel" aria-label="Controls">
1409
1484
  <div class="rk-inpaint-block">
1410
1485
  <label class="rk-label">Source image</label>
@@ -1414,98 +1489,97 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1414
1489
  <span class="rk-inpaint-drop-name" data-custom-inpaint-source-name>No image selected</span>
1415
1490
  </button>
1416
1491
  <input data-custom-inpaint-file type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" hidden />
1492
+ <span class="rk-hint">Optional — just type in the chat and the first generated image becomes your working image.</span>
1417
1493
  </div>
1418
1494
 
1419
- <div class="rk-inpaint-block">
1420
- <label class="rk-label">Whole image prompt</label>
1421
- <div class="rk-ip-promptfield">
1422
- <textarea class="rk-textarea rk-ip-global" data-editor-inpaint-global-prompt placeholder="Describe the overall edit, style, constraints, or preservation notes (optional)."></textarea>
1423
- <button type="button" class="rk-ip-attach" data-editor-inpaint-attach-scope="global" aria-label="Attach reference image" title="Attach reference image"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1424
- </div>
1425
- <span class="editor-inpaint-attachment-chips rk-ip-attach-chips" data-editor-inpaint-attachments="global"></span>
1426
- <input data-editor-inpaint-attachment-input type="file" accept="image/jpeg,image/png,image/webp" multiple hidden />
1427
- <span class="rk-hint">Guidance for the masked edit and overall consistency. It never permits changing unmasked pixels.</span>
1428
- </div>
1429
-
1430
- <div class="rk-inpaint-block">
1495
+ <div class="rk-inpaint-block rk-ip-maskblock" data-ip-mask-block hidden>
1431
1496
  <div class="rk-inpaint-brushrow">
1432
- <label class="rk-label">Brush <span data-editor-inpaint-brush-label>70</span>&nbsp;px</label>
1433
- <div class="rk-ip-toolbtns">
1434
- <button type="button" class="rk-ip-iconbtn" data-editor-inpaint-undo aria-label="Undo stroke" title="Undo"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7.2 7H3.8V3.6"/><path d="M4 7a7 7 0 1 1 1.9 6.6"/></svg></button>
1435
- <button type="button" class="rk-ip-iconbtn" data-editor-inpaint-redo aria-label="Redo stroke" title="Redo"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.8 7h3.4V3.6"/><path d="M16 7a7 7 0 1 0-1.9 6.6"/></svg></button>
1436
- <button type="button" class="rk-ip-iconbtn" data-editor-inpaint-clear aria-label="Clear mask" title="Clear mask"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l10 10M15 5 5 15"/></svg></button>
1437
- </div>
1438
- </div>
1439
- <input data-editor-inpaint-brush class="rk-inpaint-range" type="range" min="6" max="180" step="1" value="70" />
1440
- </div>
1441
-
1442
- <div class="rk-inpaint-block">
1443
- <div class="rk-inpaint-brushrow">
1444
- <label class="rk-label">Mask colors</label>
1497
+ <label class="rk-label">Mask regions</label>
1445
1498
  <input class="rk-ip-colorpicker" data-editor-inpaint-color-picker type="color" value="#ff2d55" aria-label="Mask color" />
1446
1499
  </div>
1447
- <span class="rk-hint">Paint regions in different colors, then give each color its own replacement instruction below.</span>
1500
+ <span class="rk-hint">Paint on the image in the chat. Give each color its own replacement instruction — the chat box stays the whole-image prompt.</span>
1448
1501
  <div class="editor-inpaint-swatches" data-editor-inpaint-swatches></div>
1449
1502
  <div class="editor-inpaint-prompts" data-editor-inpaint-prompts></div>
1450
1503
  <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm rk-ip-addcolor" data-editor-inpaint-add-color>+ Add color</button>
1451
1504
  </div>
1452
1505
 
1453
- <div class="rk-inpaint-block">
1454
- <button type="button" class="rk-btn rk-btn-gold rk-inpaint-generate" data-editor-inpaint-submit><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9z"/></svg> <span data-editor-inpaint-submit-label>Generate edit</span></button>
1455
- <p class="custom-inpaint-status rk-inpaint-status" data-custom-inpaint-status data-tone="" aria-live="polite"></p>
1506
+ <!-- Legacy fields the chat drives behind the scenes: the composer text is written
1507
+ into the whole-image prompt, and the composer's send press clicks submit. -->
1508
+ <div class="rk-ip-legacy" aria-hidden="true">
1509
+ <textarea class="rk-textarea rk-ip-global" data-editor-inpaint-global-prompt></textarea>
1510
+ <button type="button" class="rk-ip-attach" data-editor-inpaint-attach-scope="global" aria-label="Attach reference image"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1511
+ <span class="editor-inpaint-attachment-chips rk-ip-attach-chips" data-editor-inpaint-attachments="global"></span>
1512
+ <input data-editor-inpaint-attachment-input type="file" accept="image/jpeg,image/png,image/webp" multiple hidden />
1513
+ <button type="button" data-editor-inpaint-submit><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9z"/></svg> <span data-editor-inpaint-submit-label>Generate edit</span></button>
1514
+ <div class="custom-inpaint-output" data-custom-inpaint-output hidden>
1515
+ <button type="button" data-custom-inpaint-result-open aria-label="View image edit result">
1516
+ <img data-custom-inpaint-result alt="Image edit result" />
1517
+ </button>
1518
+ <button type="button" data-custom-inpaint-save>Save to Files</button>
1519
+ </div>
1456
1520
  </div>
1457
1521
  </section>
1458
1522
 
1459
- <!-- CENTER: canvas stage (with text-to-image empty state) -->
1460
- <section class="rk-inpaint-stage" aria-label="Canvas">
1461
- <div class="rk-inpaint-empty" data-custom-inpaint-empty>
1462
- <span class="rk-inpaint-empty-ic" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg></span>
1463
- <p class="rk-inpaint-empty-title">Upload an image to start</p>
1464
- <p class="rk-inpaint-empty-sub">Then paint mask regions in one or more colors.</p>
1465
-
1466
- <!-- Or generate an image from scratch (text-to-image) -->
1467
- <div class="rk-ip-gen" id="rk-ip-gen">
1468
- <span class="rk-ip-gen-or">or</span>
1469
- <div class="rk-ip-gen-box">
1470
- <textarea id="rk-ip-genprompt" class="rk-ip-gen-input" rows="2" placeholder="Or type your image prompt"></textarea>
1471
- <div class="rk-ip-gen-presets" id="rk-ip-genpresets">
1472
- <button type="button" class="rk-ip-preset" data-preset="character-card" title="Fill the prompt with a character reference sheet (multi-angle, expressions, poses)">+ Character card</button>
1473
- <button type="button" class="rk-ip-preset" data-preset="storyboard" title="Fill the prompt with a numbered storyboard (sequential shot panels)">+ Storyboard</button>
1474
- </div>
1475
- <div class="rk-ip-gen-refs" id="rk-ip-genrefs" hidden></div>
1476
- <div class="rk-ip-gen-bar">
1477
- <button type="button" class="rk-ip-gen-clip" id="rk-ip-genclip" aria-label="Attach reference images from your files" title="Attach reference images from your files"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1478
- <span class="rk-ip-gen-hint">Generate a brand-new image, then paint to refine it.</span>
1479
- <button type="button" class="rk-ip-gen-send" id="rk-ip-gensend" aria-label="Generate image"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg></button>
1480
- </div>
1523
+ <!-- CENTER: chat feed + composer. Every turn appends to the feed; edits always
1524
+ apply to the LATEST image, which is the one you paint on in-feed. -->
1525
+ <section class="rk-ip-chat" aria-label="Create Image chat">
1526
+ <div class="rk-ip-feed" data-ip-feed>
1527
+ <div class="rk-inpaint-empty" data-custom-inpaint-empty>
1528
+ <span class="rk-inpaint-empty-ic" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9z"/></svg></span>
1529
+ <p class="rk-inpaint-empty-title">Describe the image you want</p>
1530
+ <p class="rk-inpaint-empty-sub">Then keep chatting to refine it — every message edits the newest image. Paint a mask on it first to edit just that region.</p>
1531
+ <div class="rk-ip-gen-presets" id="rk-ip-genpresets">
1532
+ <button type="button" class="rk-ip-preset" data-preset="character-card" title="Fill the prompt with a character reference sheet (multi-angle, expressions, poses)">+ Character card</button>
1533
+ <button type="button" class="rk-ip-preset" data-preset="storyboard" title="Fill the prompt with a numbered storyboard (sequential shot panels)">+ Storyboard</button>
1481
1534
  </div>
1482
- <p class="rk-ip-gen-status" id="rk-ip-genstatus" data-tone="" aria-live="polite"></p>
1483
1535
  </div>
1484
1536
  </div>
1485
- <div class="rk-inpaint-canvaswrap editor-inpaint-canvas-wrap" data-custom-inpaint-canvas-wrap hidden>
1486
- <img data-editor-inpaint-image class="rk-inpaint-img" alt="" />
1487
- <canvas data-editor-inpaint-canvas class="rk-inpaint-canvas"></canvas>
1488
- <div class="editor-inpaint-brush-cursor" data-editor-inpaint-brush-cursor aria-hidden="true"></div>
1537
+
1538
+ <!-- The paint surface. JS moves this whole stack into the newest image bubble,
1539
+ so the brush toolbar always travels with the image it paints. -->
1540
+ <div class="rk-ip-canvasholder" data-ip-canvas-holder hidden>
1541
+ <div class="rk-ip-paintstack" data-ip-paint-stack>
1542
+ <div class="rk-inpaint-canvaswrap editor-inpaint-canvas-wrap" data-custom-inpaint-canvas-wrap hidden>
1543
+ <img data-editor-inpaint-image class="rk-inpaint-img" alt="" />
1544
+ <canvas data-editor-inpaint-canvas class="rk-inpaint-canvas"></canvas>
1545
+ <div class="editor-inpaint-brush-cursor" data-editor-inpaint-brush-cursor aria-hidden="true"></div>
1546
+ </div>
1547
+ <div class="rk-ip-painttools" data-ip-painttools>
1548
+ <span class="rk-ip-painttools-brush">
1549
+ <span class="rk-ip-painttools-label">Brush <span data-editor-inpaint-brush-label>70</span></span>
1550
+ <input data-editor-inpaint-brush class="rk-inpaint-range" type="range" min="6" max="180" step="1" value="70" aria-label="Brush size" />
1551
+ </span>
1552
+ <button type="button" class="rk-ip-painttool" data-editor-inpaint-undo aria-label="Undo stroke" title="Undo stroke (⌘Z)"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7.2 7H3.8V3.6"/><path d="M4 7a7 7 0 1 1 1.9 6.6"/></svg></button>
1553
+ <button type="button" class="rk-ip-painttool" data-editor-inpaint-redo aria-label="Redo stroke" title="Redo stroke (⇧⌘Z)"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.8 7h3.4V3.6"/><path d="M16 7a7 7 0 1 0-1.9 6.6"/></svg></button>
1554
+ <button type="button" class="rk-ip-painttool" data-editor-inpaint-clear aria-label="Clear mask" title="Clear mask"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l10 10M15 5 5 15"/></svg></button>
1555
+ </div>
1556
+ </div>
1489
1557
  </div>
1490
- </section>
1491
1558
 
1492
- <!-- RIGHT: result + tracer-grouped history -->
1493
- <section class="rk-inpaint-output" aria-label="Result and history">
1494
- <div class="custom-inpaint-output rk-inpaint-result" data-custom-inpaint-output hidden>
1495
- <div class="rk-inpaint-result-head">
1496
- <h2 class="rk-inpaint-h2">Result</h2>
1497
- <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm" data-custom-inpaint-save>Save to Files</button>
1559
+ <div class="rk-ip-composer" data-ip-composer>
1560
+ <div class="rk-ip-comp-mask" data-ip-comp-mask hidden></div>
1561
+ <div class="rk-ip-gen-refs" id="rk-ip-genrefs" hidden></div>
1562
+ <div class="rk-ip-comp-field">
1563
+ <textarea id="rk-ip-genprompt" class="rk-ip-comp-input" rows="2" placeholder="Describe the image you want…"></textarea>
1564
+ <span class="rk-ip-comp-grip" data-ip-grip role="separator" aria-label="Drag to resize the message box" title="Drag to resize"><svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M11 4 4 11"/><path d="M11 8.5 8.5 11"/></svg></span>
1498
1565
  </div>
1499
- <button type="button" class="rk-inpaint-result-imgwrap custom-inpaint-preview" data-custom-inpaint-result-open aria-label="View image edit result">
1500
- <img class="rk-inpaint-result-img" data-custom-inpaint-result alt="Image edit result" />
1501
- </button>
1566
+ <div class="rk-ip-comp-bar">
1567
+
1568
+ <button type="button" class="rk-ip-gen-clip" id="rk-ip-genclip" aria-label="Attach reference images from your files" title="Attach reference images"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1569
+ <p class="rk-ip-comp-status custom-inpaint-status" data-ip-status data-custom-inpaint-status data-tone="" aria-live="polite"></p>
1570
+ <button type="button" class="rk-ip-gen-send" id="rk-ip-gensend" aria-label="Send"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg></button>
1571
+ </div>
1572
+ <p class="rk-ip-gen-status" id="rk-ip-genstatus" data-tone="" aria-live="polite" hidden></p>
1502
1573
  </div>
1574
+ </section>
1503
1575
 
1576
+ <!-- RIGHT: tracer-grouped session history -->
1577
+ <section class="rk-inpaint-output" aria-label="Session history">
1504
1578
  <div class="rk-inpaint-history">
1505
1579
  <div class="rk-inpaint-history-head">
1506
1580
  <h2 class="rk-inpaint-h2">This session</h2>
1507
1581
  <div class="rk-ip-histtools">
1508
- <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-history-refresh aria-label="Refresh history" title="Refresh history"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 10a6.5 6.5 0 1 1-1.9-4.6"/><path d="M16.5 4.2v4.2h-4.2"/></svg></button>
1582
+ <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-history-refresh aria-label="Reload this session from the server" title="Reload this session"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 10a6.5 6.5 0 1 1-1.9-4.6"/><path d="M16.5 4.2v4.2h-4.2"/></svg></button>
1509
1583
  </div>
1510
1584
  </div>
1511
1585
  <div class="custom-inpaint-history-list rk-inpaint-historylist" data-custom-inpaint-history-list>
@@ -1553,13 +1627,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1553
1627
  <div class="custom-inpaint-lightbox-head">
1554
1628
  <p class="custom-inpaint-lightbox-title" id="custom-inpaint-lightbox-title" data-custom-inpaint-lightbox-title>Image edit output</p>
1555
1629
  <div class="custom-inpaint-lightbox-actions">
1556
- <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-lightbox-copy aria-label="Copy image URL" title="Copy URL"><svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1"/><path d="M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2"/></svg></button>
1630
+ <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-lightbox-copy aria-label="Copy file URL" title="Copy URL"><svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1"/><path d="M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2"/></svg></button>
1631
+ <a class="rk-ip-iconbtn" data-custom-inpaint-lightbox-link href="#" target="_blank" rel="noopener noreferrer" aria-label="Open the direct file URL" title="Open direct file URL"><svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8.6 11.4a3 3 0 0 0 4.3 0l2.4-2.4a3 3 0 1 0-4.3-4.3l-1 1"/><path d="M11.4 8.6a3 3 0 0 0-4.3 0l-2.4 2.4a3 3 0 1 0 4.3 4.3l1-1"/></svg></a>
1557
1632
  <a class="rk-btn rk-btn-ink rk-btn-sm" data-custom-inpaint-lightbox-download href="#" target="_blank" rel="noopener noreferrer" download><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3v9"/><path d="m6.5 8.5 3.5 3.5 3.5-3.5"/><path d="M4 16h12"/></svg> Download</a>
1558
1633
  <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-lightbox-close aria-label="Close preview" title="Close"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l10 10"/><path d="M15 5 5 15"/></svg></button>
1559
1634
  </div>
1560
1635
  </div>
1561
1636
  <div class="custom-inpaint-lightbox-stage">
1562
1637
  <img data-custom-inpaint-lightbox-image alt="Image edit output preview" />
1638
+ <video data-custom-inpaint-lightbox-video controls playsinline hidden></video>
1563
1639
  </div>
1564
1640
  </div>
1565
1641
  </div>
@@ -1651,6 +1727,282 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1651
1727
  </aside>
1652
1728
  </div>
1653
1729
  <script>
1730
+
1731
+ (function(){
1732
+ var IMAGE_RE = /\.(png|jpe?g|gif|webp|avif)$/i;
1733
+ var SEND_ICON = "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 19V5\"/><path d=\"m5 12 7-7 7 7\"/></svg>";
1734
+ var STOP_ICON = "<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"5\" y=\"5\" width=\"10\" height=\"10\" rx=\"2\"/></svg>";
1735
+ var MAX_ROWS = 10;
1736
+
1737
+ function isImageFile(f){ return /^image\//i.test((f && f.type) || '') || IMAGE_RE.test((f && f.name) || ''); }
1738
+ function imagesFromClipboard(e){
1739
+ var items = (e.clipboardData && e.clipboardData.items) || [], out = [];
1740
+ for(var i = 0; i < items.length; i++){
1741
+ if(items[i].kind === 'file'){ var f = items[i].getAsFile(); if(f && /^image\//i.test(f.type || '')) out.push(f); }
1742
+ }
1743
+ return out;
1744
+ }
1745
+ function dtHasFiles(e){
1746
+ var t = (e.dataTransfer && e.dataTransfer.types) || [];
1747
+ return Array.prototype.indexOf.call(t, 'Files') !== -1;
1748
+ }
1749
+ function plural(n, word){ return n + ' ' + word + (n > 1 ? 's' : ''); }
1750
+
1751
+ window.__rkChatComposer = function(cfg){
1752
+ cfg = cfg || {};
1753
+ var root = cfg.root, ta = cfg.textarea, refsEl = cfg.refsEl;
1754
+ var sendBtn = cfg.sendBtn, clipBtn = cfg.clipBtn;
1755
+ var uploadBlob = cfg.uploadBlob || function(){ return Promise.reject(new Error('Uploads are unavailable.')); };
1756
+ var status = cfg.onStatus || function(){};
1757
+ var canSendExtra = cfg.canSend || function(){ return false; };
1758
+ var refs = [];
1759
+ var busy = false;
1760
+ // Once the user drags the grip, their height wins: auto-grow stops fighting
1761
+ // them. Sending clears it so the next message starts compact again.
1762
+ var manualHeight = 0;
1763
+
1764
+ // ---- height ------------------------------------------------------------
1765
+ function maxHeight(){
1766
+ if(!ta) return 0;
1767
+ var s = window.getComputedStyle(ta);
1768
+ var line = parseFloat(s.lineHeight) || 20;
1769
+ var pad = (parseFloat(s.paddingTop) || 0) + (parseFloat(s.paddingBottom) || 0)
1770
+ + (parseFloat(s.borderTopWidth) || 0) + (parseFloat(s.borderBottomWidth) || 0);
1771
+ return Math.round(line * MAX_ROWS + pad);
1772
+ }
1773
+ function applyHeight(px, cap){
1774
+ ta.style.height = px + 'px';
1775
+ ta.style.overflowY = ta.scrollHeight > px + 1 ? 'auto' : (cap ? 'auto' : 'hidden');
1776
+ }
1777
+ function autoGrow(){
1778
+ if(!ta) return;
1779
+ if(manualHeight){ applyHeight(manualHeight, true); return; }
1780
+ var max = maxHeight();
1781
+ ta.style.height = 'auto';
1782
+ applyHeight(Math.min(max, Math.max(56, ta.scrollHeight)), ta.scrollHeight > max);
1783
+ }
1784
+ function resetHeight(){ manualHeight = 0; if(ta){ ta.style.height = 'auto'; } autoGrow(); }
1785
+
1786
+ // Corner grip: pointer-drag the textarea taller/shorter. Bounded so it can
1787
+ // never collapse under one row or grow past the viewport.
1788
+ var field = ta && ta.parentElement;
1789
+ var grip = root && root.querySelector('[data-ip-grip]');
1790
+ if(grip && ta){
1791
+ grip.addEventListener('pointerdown', function(e){
1792
+ e.preventDefault();
1793
+ var startY = e.clientY, startH = ta.getBoundingClientRect().height;
1794
+ var maxPx = Math.max(120, Math.round(window.innerHeight * 0.6));
1795
+ try{ grip.setPointerCapture(e.pointerId); }catch(_){}
1796
+ if(field) field.classList.add('is-resizing');
1797
+ function move(ev){
1798
+ var next = Math.min(maxPx, Math.max(56, Math.round(startH + (ev.clientY - startY))));
1799
+ manualHeight = next;
1800
+ applyHeight(next, true);
1801
+ }
1802
+ function up(ev){
1803
+ try{ grip.releasePointerCapture(e.pointerId); }catch(_){}
1804
+ if(field) field.classList.remove('is-resizing');
1805
+ grip.removeEventListener('pointermove', move);
1806
+ grip.removeEventListener('pointerup', up);
1807
+ grip.removeEventListener('pointercancel', up);
1808
+ // A click with no travel resets to auto-grow — an escape hatch out of
1809
+ // a height you dragged and no longer want.
1810
+ if(Math.abs(ev.clientY - startY) < 3) resetHeight();
1811
+ }
1812
+ grip.addEventListener('pointermove', move);
1813
+ grip.addEventListener('pointerup', up);
1814
+ grip.addEventListener('pointercancel', up);
1815
+ });
1816
+ grip.addEventListener('dblclick', function(){ resetHeight(); });
1817
+ }
1818
+
1819
+ // ---- send / stop button ------------------------------------------------
1820
+ function syncSend(){
1821
+ if(!sendBtn) return;
1822
+ if(busy){
1823
+ sendBtn.disabled = false;
1824
+ sendBtn.dataset.mode = 'stop';
1825
+ sendBtn.setAttribute('aria-label', 'Stop generating');
1826
+ sendBtn.setAttribute('title', 'Stop generating');
1827
+ sendBtn.innerHTML = STOP_ICON;
1828
+ return;
1829
+ }
1830
+ sendBtn.dataset.mode = 'send';
1831
+ sendBtn.setAttribute('aria-label', 'Send');
1832
+ sendBtn.setAttribute('title', 'Send');
1833
+ sendBtn.disabled = !(Boolean((ta && ta.value || '').trim()) || canSendExtra());
1834
+ sendBtn.innerHTML = SEND_ICON;
1835
+ }
1836
+ function setBusy(v){
1837
+ busy = Boolean(v);
1838
+ if(root) root.classList.toggle('is-busy', busy);
1839
+ syncSend();
1840
+ }
1841
+
1842
+ // ---- reference chips ---------------------------------------------------
1843
+ function renderRefs(){
1844
+ if(!refsEl) return;
1845
+ refsEl.hidden = refs.length === 0;
1846
+ refsEl.innerHTML = '';
1847
+ refs.forEach(function(url, idx){
1848
+ var chip = document.createElement('span'); chip.className = 'rk-ip-gen-ref';
1849
+ var im = document.createElement('img'); im.src = url; im.alt = ''; im.loading = 'lazy'; chip.appendChild(im);
1850
+ var lbl = document.createElement('span');
1851
+ var nm = url.split('/').pop() || 'reference';
1852
+ lbl.textContent = decodeURIComponent(nm).slice(0, 28);
1853
+ chip.appendChild(lbl);
1854
+ var x = document.createElement('button'); x.type = 'button';
1855
+ x.setAttribute('aria-label', 'Remove reference'); x.innerHTML = '&times;';
1856
+ x.addEventListener('click', function(){ refs.splice(idx, 1); renderRefs(); });
1857
+ chip.appendChild(x);
1858
+ refsEl.appendChild(chip);
1859
+ });
1860
+ }
1861
+
1862
+ // Paste or drop image files straight into the composer -> temp upload -> ref.
1863
+ function addFiles(fileList){
1864
+ var files = Array.prototype.slice.call(fileList || []).filter(isImageFile);
1865
+ if(!files.length) return Promise.resolve(0);
1866
+ status('Uploading ' + plural(files.length, 'reference') + '\u2026', '');
1867
+ var added = 0;
1868
+ return files.reduce(function(chain, f){
1869
+ return chain.then(function(){
1870
+ return uploadBlob(f, f.name || 'pasted-image.png', f.type || 'image/png').then(function(url){
1871
+ if(url && refs.indexOf(url) === -1){ refs.push(url); added++; renderRefs(); }
1872
+ });
1873
+ });
1874
+ }, Promise.resolve()).then(function(){
1875
+ if(added) status(plural(added, 'reference') + ' attached.', 'success');
1876
+ return added;
1877
+ }).catch(function(err){
1878
+ status((err && err.message) ? err.message : 'Upload failed.', 'error');
1879
+ return added;
1880
+ });
1881
+ }
1882
+
1883
+ // ---- attach pop panel (shared file-directory explorer) -----------------
1884
+ var modal = null;
1885
+ function onEsc(e){ if(e.key === 'Escape') closePanel(); }
1886
+ function closePanel(){
1887
+ if(!modal) return;
1888
+ try{ modal._handle && modal._handle.destroy && modal._handle.destroy(); }catch(_){}
1889
+ modal.remove(); modal = null;
1890
+ document.removeEventListener('keydown', onEsc, true);
1891
+ }
1892
+ function openPanel(){
1893
+ if(modal) return;
1894
+ var overlay = document.createElement('div'); overlay.className = 'rk-ip-filemodal';
1895
+ var panel = document.createElement('div'); panel.className = 'rk-ip-filemodal-panel';
1896
+ var head = document.createElement('div'); head.className = 'rk-ip-filemodal-head';
1897
+ var title = document.createElement('span'); title.className = 'rk-ip-filemodal-title';
1898
+ title.textContent = cfg.attachTitle || 'Attach reference images';
1899
+ var actions = document.createElement('div'); actions.className = 'rk-ip-filemodal-actions';
1900
+ var uploadBtn = document.createElement('button'); uploadBtn.type = 'button';
1901
+ uploadBtn.className = 'rk-ip-filemodal-upload'; uploadBtn.textContent = '\u2191 Upload from computer';
1902
+ var uploadInput = document.createElement('input'); uploadInput.type = 'file';
1903
+ uploadInput.accept = 'image/png,image/jpeg,image/webp,image/gif,image/avif';
1904
+ uploadInput.multiple = true; uploadInput.hidden = true;
1905
+ var close = document.createElement('button'); close.type = 'button';
1906
+ close.className = 'rk-ip-filemodal-close'; close.setAttribute('aria-label', 'Close'); close.innerHTML = '&times;';
1907
+ actions.appendChild(uploadBtn); actions.appendChild(close);
1908
+ head.appendChild(title); head.appendChild(actions); head.appendChild(uploadInput);
1909
+ uploadBtn.addEventListener('click', function(){ if(!uploadBtn.disabled) uploadInput.click(); });
1910
+ uploadInput.addEventListener('change', function(){
1911
+ var files = Array.prototype.slice.call(uploadInput.files || []).filter(isImageFile);
1912
+ uploadInput.value = '';
1913
+ if(!files.length){ status('Pick an image file to upload as a reference.', 'error'); return; }
1914
+ var original = uploadBtn.textContent;
1915
+ uploadBtn.disabled = true; uploadBtn.textContent = 'Uploading\u2026';
1916
+ addFiles(files).then(function(added){
1917
+ uploadBtn.disabled = false; uploadBtn.textContent = original;
1918
+ if(added) closePanel();
1919
+ else status('Those files were already attached.', '');
1920
+ });
1921
+ });
1922
+ var mountEl = document.createElement('div'); mountEl.className = 'rk-ip-filemodal-mount';
1923
+ panel.appendChild(head); panel.appendChild(mountEl); overlay.appendChild(panel);
1924
+ document.body.appendChild(overlay); modal = overlay;
1925
+ close.addEventListener('click', closePanel);
1926
+ overlay.addEventListener('click', function(e){ if(e.target === overlay) closePanel(); });
1927
+ document.addEventListener('keydown', onEsc, true);
1928
+ if(window.__vidfarmDirectory && window.__vidfarmDirectory.mount){
1929
+ modal._handle = window.__vidfarmDirectory.mount(mountEl, {
1930
+ mode: 'attach', showMultiSelect: true, primaryClick: 'attach',
1931
+ onAttach: function(items){
1932
+ var added = 0;
1933
+ (items || []).forEach(function(it){
1934
+ var ct = it.contentType || '';
1935
+ var isImg = ct.indexOf('image/') === 0 || /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(it.name || '');
1936
+ if(it.viewUrl && isImg && refs.indexOf(it.viewUrl) === -1){ refs.push(it.viewUrl); added++; }
1937
+ });
1938
+ renderRefs();
1939
+ if(added){ status(plural(added, 'reference') + ' attached.', 'success'); closePanel(); }
1940
+ else status('Pick an image file to use as a reference.', 'error');
1941
+ }
1942
+ });
1943
+ } else {
1944
+ mountEl.innerHTML = '<p style="padding:16px;color:var(--rk-text-muted);font-size:13px">File browser is unavailable right now.</p>';
1945
+ }
1946
+ }
1947
+
1948
+ // ---- wiring ------------------------------------------------------------
1949
+ if(ta){
1950
+ ta.addEventListener('input', function(){ autoGrow(); syncSend(); });
1951
+ ta.addEventListener('keydown', function(e){
1952
+ if(e.key !== 'Enter' || e.shiftKey) return;
1953
+ e.preventDefault();
1954
+ // Enter is ignored mid-turn so an impatient double-send cannot queue a
1955
+ // second job; the draft stays put.
1956
+ if(busy) return;
1957
+ if(cfg.onSend) cfg.onSend();
1958
+ });
1959
+ ta.addEventListener('paste', function(e){
1960
+ var imgs = imagesFromClipboard(e);
1961
+ if(imgs.length){ e.preventDefault(); addFiles(imgs); }
1962
+ });
1963
+ }
1964
+ if(clipBtn) clipBtn.addEventListener('click', openPanel);
1965
+ if(sendBtn) sendBtn.addEventListener('click', function(){
1966
+ if(busy){ if(cfg.onStop) cfg.onStop(); return; }
1967
+ if(cfg.onSend) cfg.onSend();
1968
+ });
1969
+ if(root){
1970
+ ['dragenter', 'dragover'].forEach(function(ev){
1971
+ root.addEventListener(ev, function(e){
1972
+ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); root.classList.add('is-dragref'); }
1973
+ });
1974
+ });
1975
+ ['dragleave', 'dragend'].forEach(function(ev){
1976
+ root.addEventListener(ev, function(){ root.classList.remove('is-dragref'); });
1977
+ });
1978
+ root.addEventListener('drop', function(e){
1979
+ if(!dtHasFiles(e)) return;
1980
+ e.preventDefault(); e.stopPropagation();
1981
+ root.classList.remove('is-dragref');
1982
+ addFiles(e.dataTransfer.files);
1983
+ });
1984
+ }
1985
+
1986
+ autoGrow();
1987
+ syncSend();
1988
+
1989
+ return {
1990
+ refs: function(){ return refs.slice(); },
1991
+ setRefs: function(list){ refs = (list || []).slice(); renderRefs(); },
1992
+ clearRefs: function(){ refs = []; renderRefs(); },
1993
+ addFiles: addFiles,
1994
+ renderRefs: renderRefs,
1995
+ autoGrow: autoGrow,
1996
+ resetHeight: resetHeight,
1997
+ setBusy: setBusy,
1998
+ isBusy: function(){ return busy; },
1999
+ syncSend: syncSend,
2000
+ closePanel: closePanel,
2001
+ setPlaceholder: function(s){ if(ta) ta.placeholder = s; }
2002
+ };
2003
+ };
2004
+ })();
2005
+
1654
2006
  (function(){
1655
2007
  const uploadButton = document.querySelector("[data-custom-inpaint-upload]");
1656
2008
  const startNewButton = document.querySelector("[data-custom-inpaint-start-new]");
@@ -1697,7 +2049,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1697
2049
  const lightboxImage = document.querySelector("[data-custom-inpaint-lightbox-image]");
1698
2050
  const lightboxCopy = document.querySelector("[data-custom-inpaint-lightbox-copy]");
1699
2051
  const lightboxDownload = document.querySelector("[data-custom-inpaint-lightbox-download]");
2052
+ const lightboxLink = document.querySelector("[data-custom-inpaint-lightbox-link]");
2053
+ const lightboxVideo = document.querySelector("[data-custom-inpaint-lightbox-video]");
1700
2054
  const lightboxClose = document.querySelector("[data-custom-inpaint-lightbox-close]");
2055
+ // chat surface
2056
+ const chatFeed = document.querySelector("[data-ip-feed]");
2057
+ const canvasHolder = document.querySelector("[data-ip-canvas-holder]");
2058
+ const composerEl = document.querySelector("[data-ip-composer]");
2059
+ const maskBlock = document.querySelector("[data-ip-mask-block]");
2060
+ const maskChip = document.querySelector("[data-ip-comp-mask]");
2061
+ const paintStack = document.querySelector("[data-ip-paint-stack]");
1701
2062
  if (!(uploadButton instanceof HTMLButtonElement)
1702
2063
  || !(startNewButton instanceof HTMLButtonElement)
1703
2064
  || !(fileInput instanceof HTMLInputElement)
@@ -1737,7 +2098,13 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1737
2098
  || !(lightboxImage instanceof HTMLImageElement)
1738
2099
  || !(lightboxCopy instanceof HTMLButtonElement)
1739
2100
  || !(lightboxDownload instanceof HTMLAnchorElement)
1740
- || !(lightboxClose instanceof HTMLButtonElement)) {
2101
+ || !(lightboxClose instanceof HTMLButtonElement)
2102
+ || !(chatFeed instanceof HTMLElement)
2103
+ || !(canvasHolder instanceof HTMLElement)
2104
+ || !(composerEl instanceof HTMLElement)
2105
+ || !(maskBlock instanceof HTMLElement)
2106
+ || !(maskChip instanceof HTMLElement)
2107
+ || !(paintStack instanceof HTMLElement)) {
1741
2108
  return;
1742
2109
  }
1743
2110
 
@@ -1752,13 +2119,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1752
2119
  let inpaintAttachments = [];
1753
2120
  let pendingInpaintAttachmentScope = null;
1754
2121
  let inpaintHistoryItems = [];
2122
+ // Chat state. Declared up here because the legacy helpers below (loadSourceUrl,
2123
+ // applyCompletedInpaintJob, resetWorkspace) push turns into it.
2124
+ let chatTurns = [];
2125
+ let activeTurnId = "";
1755
2126
  // Local "starting image" history entries (uploaded / generated / deep-linked
1756
2127
  // sources). They are not job runs, so they survive server-history reloads by
1757
2128
  // being re-injected after each rebuild — this lets users see where a session
1758
2129
  // started from.
1759
- let sourceHistoryItems = [];
1760
2130
  let historyTracerRefreshTimer = 0;
1761
- const hiddenInpaintHistoryKeys = new Set();
1762
2131
  const maxInpaintAttachments = 8;
1763
2132
  const allowedInpaintAttachmentTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
1764
2133
 
@@ -1831,12 +2200,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1831
2200
  : (sessionLocked ? "Edit generated \u2014 Start New to edit again" : "Generate edit");
1832
2201
  }
1833
2202
 
1834
- // Once an edit is generated the inputs that produced it are frozen (prompts,
1835
- // masks, brush, colors, references, source) so the record stays truthful —
1836
- // to make another edit the user hits Start New (or opens/loads another image).
2203
+ // The chat surface iterates forever on the newest image, so the old
2204
+ // freeze-after-one-edit lock is gone: setSessionLocked() is kept as a no-op
2205
+ // so every legacy call site still type-checks and simply never locks.
1837
2206
  let sessionLocked = false;
1838
2207
  function applySessionLock() {
1839
- const locked = sessionLocked;
2208
+ const locked = false;
1840
2209
  const workbench = document.querySelector(".rk-inpaint-workbench");
1841
2210
  if (workbench instanceof HTMLElement) workbench.classList.toggle("is-locked", locked);
1842
2211
  inpaintGlobalPrompt.readOnly = locked;
@@ -1854,8 +2223,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1854
2223
  // Keep the submit button + label consistent with the lock while not loading.
1855
2224
  if (inpaintSubmit.getAttribute("data-loading") !== "true") setSubmitLoading(false);
1856
2225
  }
1857
- function setSessionLocked(locked) {
1858
- sessionLocked = Boolean(locked);
2226
+ function setSessionLocked() {
2227
+ sessionLocked = false;
1859
2228
  applySessionLock();
1860
2229
  }
1861
2230
 
@@ -1873,15 +2242,31 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1873
2242
  return prefix + "-" + stamp + ".png";
1874
2243
  }
1875
2244
 
1876
- function openImagePreview(url, label, downloadName) {
2245
+ // Fullscreen preview for any feed item — images and generated videos alike.
2246
+ function openImagePreview(url, label, downloadName, mediaKind) {
1877
2247
  if (!url) return;
1878
- lightboxImage.src = url;
1879
- lightboxImage.alt = label || "Image edit output preview";
2248
+ const isVideo = mediaKind === "video";
2249
+ if (isVideo && lightboxVideo instanceof HTMLVideoElement) {
2250
+ lightboxVideo.src = url;
2251
+ lightboxVideo.hidden = false;
2252
+ lightboxImage.hidden = true;
2253
+ lightboxImage.removeAttribute("src");
2254
+ } else {
2255
+ if (lightboxVideo instanceof HTMLVideoElement) {
2256
+ lightboxVideo.pause();
2257
+ lightboxVideo.removeAttribute("src");
2258
+ lightboxVideo.hidden = true;
2259
+ }
2260
+ lightboxImage.hidden = false;
2261
+ lightboxImage.src = url;
2262
+ lightboxImage.alt = label || "Image edit output preview";
2263
+ }
1880
2264
  lightboxTitle.textContent = label || "Image edit output";
1881
2265
  lightboxCopy.dataset.copyUrl = url;
1882
2266
  lightboxCopy.setAttribute("title", "Copy URL");
1883
2267
  lightboxDownload.href = url;
1884
- lightboxDownload.setAttribute("download", downloadName || imageDownloadName("inpaint-output"));
2268
+ lightboxDownload.setAttribute("download", downloadName || imageDownloadName(isVideo ? "video-output" : "inpaint-output"));
2269
+ if (lightboxLink instanceof HTMLAnchorElement) lightboxLink.href = url;
1885
2270
  lightbox.hidden = false;
1886
2271
  lightbox.setAttribute("data-open", "true");
1887
2272
  document.body.style.overflow = "hidden";
@@ -1891,9 +2276,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1891
2276
  lightbox.setAttribute("data-open", "false");
1892
2277
  lightbox.hidden = true;
1893
2278
  lightboxImage.removeAttribute("src");
2279
+ if (lightboxVideo instanceof HTMLVideoElement) {
2280
+ lightboxVideo.pause();
2281
+ lightboxVideo.removeAttribute("src");
2282
+ lightboxVideo.hidden = true;
2283
+ }
1894
2284
  lightboxCopy.dataset.copyUrl = "";
1895
2285
  lightboxCopy.setAttribute("title", "Copy URL");
1896
2286
  lightboxDownload.href = "#";
2287
+ if (lightboxLink instanceof HTMLAnchorElement) lightboxLink.href = "#";
1897
2288
  document.body.style.overflow = "";
1898
2289
  }
1899
2290
 
@@ -2104,6 +2495,31 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2104
2495
  }
2105
2496
  renderAllAttachmentChips();
2106
2497
  applySessionLock();
2498
+ syncMaskUi();
2499
+ }
2500
+
2501
+ // The mask controls only exist while a mask exists: the left block holds the
2502
+ // per-color instructions, and the composer grows a chip so it is obvious the
2503
+ // next message is a MASKED edit rather than a whole-image one.
2504
+ function syncMaskUi() {
2505
+ const painted = inpaintStrokes.length > 0;
2506
+ const showBlock = Boolean(sourceImageUrl) && (painted || redoStrokes.length > 0);
2507
+ maskBlock.hidden = !showBlock;
2508
+ const usedColors = new Set(inpaintStrokes.map((stroke) => normalizeHexColor(stroke.color)));
2509
+ maskChip.hidden = !painted;
2510
+ if (painted) {
2511
+ maskChip.innerHTML = "";
2512
+ const label = document.createElement("span");
2513
+ label.textContent = "Masked edit \u00b7 " + usedColors.size + " region" + (usedColors.size === 1 ? "" : "s") + " painted";
2514
+ const clear = document.createElement("button");
2515
+ clear.type = "button";
2516
+ clear.dataset.ipClearMask = "true";
2517
+ clear.textContent = "Clear mask";
2518
+ maskChip.append(label, clear);
2519
+ }
2520
+ inpaintUndo.disabled = !inpaintStrokes.length;
2521
+ inpaintRedo.disabled = !redoStrokes.length;
2522
+ inpaintClear.disabled = !painted;
2107
2523
  }
2108
2524
 
2109
2525
  function redrawInpaintMask() {
@@ -2161,6 +2577,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2161
2577
  inpaintStrokes.push(currentInpaintStroke);
2162
2578
  redoStrokes = [];
2163
2579
  redrawInpaintMask();
2580
+ syncMaskUi();
2164
2581
  }
2165
2582
 
2166
2583
  function continueInpaintStroke(event) {
@@ -2451,22 +2868,17 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2451
2868
  setStatus("The source image URL is invalid.", "error");
2452
2869
  return;
2453
2870
  }
2454
- setSessionLocked(false);
2455
- sourceImageUrl = url;
2456
2871
  sourceImageName = firstSearchParam(["source_image_name", "image_name", "file_name"]) || sourceNameFromUrl(url);
2457
- sourceName.textContent = sourceImageName;
2458
- emptyState.hidden = true;
2459
- canvasWrap.hidden = false;
2460
- outputPanel.hidden = true;
2461
- resultImage.removeAttribute("src");
2462
- resetMask();
2463
- inpaintImage.onload = () => {
2464
- syncInpaintCanvasSize();
2465
- resetMask();
2466
- };
2467
- inpaintImage.src = sourceImageUrl;
2468
- recordSourceHistoryItem(sourceImageUrl, origin || "url", sourceImageName);
2469
- setStatus("Source ready from URL.", "success");
2872
+ adoptLatestImage(url, sourceImageName);
2873
+ addTurn({
2874
+ role: "assistant",
2875
+ kind: "media",
2876
+ mediaKind: "image",
2877
+ url,
2878
+ note: origin === "generated" ? "generated" : "starting image"
2879
+ });
2880
+ recordSourceHistoryItem(url, origin || "url", sourceImageName);
2881
+ setStatus("Working image ready — describe an edit, or paint a mask on it first.", "success");
2470
2882
  }
2471
2883
 
2472
2884
  function clearInpaintUrlParams() {
@@ -2539,6 +2951,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2539
2951
  currentInpaintStroke = null;
2540
2952
  isInpaintPainting = false;
2541
2953
  redrawInpaintMask();
2954
+ syncMaskUi();
2542
2955
  }
2543
2956
 
2544
2957
  function resetWorkspace() {
@@ -2546,7 +2959,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2546
2959
  sourceImageUrl = "";
2547
2960
  sourceImageName = "";
2548
2961
  sourceName.textContent = "No image selected";
2549
- emptyState.hidden = false;
2550
2962
  canvasWrap.hidden = true;
2551
2963
  outputPanel.hidden = true;
2552
2964
  resultImage.removeAttribute("src");
@@ -2559,9 +2971,11 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2559
2971
  pendingInpaintAttachmentScope = null;
2560
2972
  activeInpaintColor = "#ff2d55";
2561
2973
  inpaintColorPicker.value = activeInpaintColor;
2562
- sourceHistoryItems = [];
2974
+ chatTurns = [];
2975
+ activeTurnId = "";
2563
2976
  resetMask();
2564
2977
  renderInpaintControls();
2978
+ renderChat();
2565
2979
  setStatus("", "");
2566
2980
  }
2567
2981
 
@@ -2580,26 +2994,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2580
2994
  const wasAutoPending = tracerAutoPending;
2581
2995
  if (wasAutoPending) nameTracerFromSeed(fileName.replace(/\.[a-z0-9]+$/i, ""));
2582
2996
  const uploadedUrl = await uploadTemporaryBlob(file, fileName, file.type || "image/png", "inpaints/" + getTracerValue());
2583
- setSessionLocked(false);
2584
- sourceImageUrl = absoluteUrl(uploadedUrl);
2585
- sourceImageName = fileName;
2586
- sourceName.textContent = fileName;
2587
- emptyState.hidden = true;
2588
- canvasWrap.hidden = false;
2589
- outputPanel.hidden = true;
2590
- resultImage.removeAttribute("src");
2591
- resetMask();
2592
- inpaintImage.onload = () => {
2593
- syncInpaintCanvasSize();
2594
- resetMask();
2595
- };
2596
- inpaintImage.src = sourceImageUrl;
2997
+ const uploaded = absoluteUrl(uploadedUrl);
2998
+ adoptLatestImage(uploaded, fileName);
2999
+ addTurn({ role: "assistant", kind: "media", mediaKind: "image", url: uploaded, note: "uploaded image" });
2597
3000
  if (wasAutoPending) {
2598
3001
  void loadInpaintHistory();
2599
3002
  void loadTracerSessions();
2600
3003
  }
2601
- recordSourceHistoryItem(sourceImageUrl, "upload", fileName);
2602
- setStatus("Source ready.", "success");
3004
+ recordSourceHistoryItem(uploaded, "upload", fileName);
3005
+ setStatus("Uploaded — describe an edit, or paint a mask on it first.", "success");
2603
3006
  } catch (error) {
2604
3007
  console.error(error);
2605
3008
  setStatus(error instanceof Error ? error.message : "Unable to upload source image.", "error");
@@ -2641,48 +3044,11 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2641
3044
  };
2642
3045
  }
2643
3046
 
2644
- function upsertInpaintHistoryItem(item) {
2645
- if (!item || !item.key) return;
2646
- inpaintHistoryItems = inpaintHistoryItems.filter((entry) => (
2647
- entry.key !== item.key
2648
- && (!item.jobId || entry.jobId !== item.jobId)
2649
- ));
2650
- inpaintHistoryItems.unshift(item);
2651
- inpaintHistoryItems.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0));
2652
- renderInpaintHistory();
2653
- }
2654
-
2655
- // Record the image a session started from (upload / generation / deep-link) so
2656
- // it appears in History. De-duplicated by URL; kept in sourceHistoryItems so a
2657
- // subsequent server-history reload doesn't drop it.
2658
- function recordSourceHistoryItem(url, origin, name) {
2659
- if (!url) return;
2660
- let abs = "";
2661
- try { abs = absoluteUrl(url); } catch { return; }
2662
- const key = "source:" + abs;
2663
- let item = sourceHistoryItems.find((entry) => entry.key === key);
2664
- if (!item) {
2665
- item = {
2666
- key,
2667
- kind: "source",
2668
- origin: origin || "upload",
2669
- url: abs,
2670
- name: name || sourceNameFromUrl(abs),
2671
- createdAt: new Date().toISOString()
2672
- };
2673
- sourceHistoryItems.push(item);
2674
- }
2675
- upsertInpaintHistoryItem(item);
2676
- }
2677
-
2678
- function reinjectSourceHistoryItems() {
2679
- for (const item of sourceHistoryItems) {
2680
- if (!inpaintHistoryItems.some((entry) => entry.key === item.key)) {
2681
- inpaintHistoryItems.push(item);
2682
- }
2683
- }
2684
- inpaintHistoryItems.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0));
2685
- }
3047
+ // The right-hand session rail is DERIVED from the chat (see syncSessionAssets),
3048
+ // so the old push-based item store is gone. These two shims stay because the
3049
+ // legacy job-polling paths still call them; the chat is the source of truth.
3050
+ function upsertInpaintHistoryItem() {}
3051
+ function recordSourceHistoryItem() {}
2686
3052
 
2687
3053
  // Mirror each completed inpaint output into /temp/inpaints/<tracer>/ so a
2688
3054
  // session's outputs are browsable as files (grouped by the same tracer that
@@ -2713,16 +3079,11 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2713
3079
  const url = absoluteUrl(outputUrl);
2714
3080
  const jobId = String(payload?.job_id || payload?.jobId || "");
2715
3081
  resultImage.src = url;
2716
- outputPanel.hidden = false;
2717
- setSessionLocked(true);
3082
+ // The finished edit becomes the newest image in the chat — and therefore
3083
+ // the thing the next message edits and the surface you paint on.
3084
+ resolveActiveTurn({ kind: "media", mediaKind: "image", url, jobId });
3085
+ adoptLatestImage(url);
2718
3086
  void persistInpaintOutputToTemp(url, jobId);
2719
- upsertInpaintHistoryItem({
2720
- key: historyItemKey({ jobId: jobId || "inpaint" }, url, 0),
2721
- kind: "output",
2722
- jobId,
2723
- url,
2724
- createdAt: String(payload?.completed_at || payload?.updated_at || new Date().toISOString())
2725
- });
2726
3087
  return url;
2727
3088
  }
2728
3089
 
@@ -2736,12 +3097,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2736
3097
  try {
2737
3098
  const parsed = new URL(value, window.location.href);
2738
3099
  const storageKey = parsed.searchParams.get("key") || decodeURIComponent(parsed.pathname).replace(/^\/+/, "");
2739
- return !/\/temporary\/inpaints\/_inputs(?:\/|$)/i.test(storageKey)
3100
+ return !/\/temporary\/inpaints\/_(?:inputs|context)(?:\/|$)/i.test(storageKey)
2740
3101
  && !/\/temporary\/inpaint(?:-|\/|$)/i.test(storageKey)
2741
3102
  && !/\/temporary\/inpaint-masks(?:\/|$)/i.test(storageKey)
2742
3103
  && !/inpaint-mask-[^/]+\.png/i.test(storageKey);
2743
3104
  } catch {
2744
- return !/\/temporary\/inpaints\/_inputs(?:\/|$)/i.test(value)
3105
+ return !/\/temporary\/inpaints\/_(?:inputs|context)(?:\/|$)/i.test(value)
2745
3106
  && !/\/temporary\/inpaint(?:-|\/|$)/i.test(value)
2746
3107
  && !/\/temporary\/inpaint-masks(?:\/|$)/i.test(value)
2747
3108
  && !/inpaint-mask-[^/]+\.png/i.test(value);
@@ -2749,16 +3110,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2749
3110
  });
2750
3111
  }
2751
3112
 
2752
- function isInpaintHistoryEntry(entry) {
2753
- const templateId = String(entry?.linkedJob?.template_id || entry?.linkedJob?.templateId || "").toLowerCase();
3113
+ // Which chat turn an api-history entry represents. These four routes ARE the
3114
+ // conversation everything else in the customer's run history is ignored here.
3115
+ function chatEntryKind(entry) {
2754
3116
  const path = String(entry?.path || "").toLowerCase();
2755
- return templateId === "primitive:image_inpaint"
2756
- || path.includes("/primitives/images/inpaint")
2757
- || path.includes("/primitives/image_inpaint");
3117
+ const templateId = String(entry?.linkedJob?.template_id || entry?.linkedJob?.templateId || "").toLowerCase();
3118
+ if (path.includes("/primitives/images/inpaint") || templateId === "primitive:image_inpaint") return "inpaint";
3119
+ if (path.includes("/primitives/images/edit") || templateId === "primitive:image_edit") return "edit";
3120
+ if (path.includes("/primitives/images/generate") || templateId === "primitive:image_generate") return "generate";
3121
+ if (path.includes("/primitives/videos/generate") || templateId === "primitive:video_generate") return "video";
3122
+ return "";
2758
3123
  }
2759
3124
 
2760
- function historyItemKey(entry, url, index) {
2761
- return String(entry?.jobId || entry?.job_id || entry?.id || "inpaint") + ":" + index + ":" + String(url || "");
3125
+ function historyEntryVideoUrls(entry) {
3126
+ const urls = Array.isArray(entry?.outputMediaUrls) ? entry.outputMediaUrls : [];
3127
+ return urls.filter((url) => /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(String(url || "")));
2762
3128
  }
2763
3129
 
2764
3130
  function setHistoryEmpty(message) {
@@ -2769,14 +3135,46 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2769
3135
  historyList.append(empty);
2770
3136
  }
2771
3137
 
3138
+ // ---- right rail: every asset this session produced, derived from the chat ----
3139
+ function syncSessionAssets() {
3140
+ const items = [];
3141
+ for (let i = chatTurns.length - 1; i >= 0; i--) {
3142
+ const turn = chatTurns[i];
3143
+ if (turn.role !== "assistant") continue;
3144
+ if (turn.kind === "media" && turn.url) {
3145
+ items.push({
3146
+ key: turn.id,
3147
+ kind: "output",
3148
+ mediaKind: turn.mediaKind === "video" ? "video" : "image",
3149
+ url: turn.url,
3150
+ jobId: turn.jobId || "",
3151
+ title: turn.prompt || turn.note || (turn.mediaKind === "video" ? "Video" : "Image"),
3152
+ createdAt: turn.at
3153
+ });
3154
+ } else if (turn.kind === "pending") {
3155
+ items.push({
3156
+ key: turn.id,
3157
+ kind: "pending",
3158
+ jobId: turn.jobId || "",
3159
+ title: turn.prompt || "Working…",
3160
+ status: turn.label || "",
3161
+ createdAt: turn.at
3162
+ });
3163
+ }
3164
+ }
3165
+ inpaintHistoryItems = items;
3166
+ renderInpaintHistory();
3167
+ }
3168
+
2772
3169
  function renderInpaintHistory() {
2773
3170
  historyList.innerHTML = "";
2774
- const visible = inpaintHistoryItems.filter((item) => !hiddenInpaintHistoryKeys.has(item.key));
2775
- if (!visible.length) {
2776
- setHistoryEmpty("No inpaint outputs yet.");
3171
+ if (!inpaintHistoryItems.length) {
3172
+ setHistoryEmpty(resolveTracer()
3173
+ ? "Nothing in this session yet. Send a message to create your first image."
3174
+ : "New session — outputs land here as you chat.");
2777
3175
  return;
2778
3176
  }
2779
- for (const item of visible) {
3177
+ for (const item of inpaintHistoryItems) {
2780
3178
  const row = document.createElement("div");
2781
3179
  row.className = "custom-inpaint-history-item";
2782
3180
  row.dataset.historyKey = item.key;
@@ -2785,158 +3183,83 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2785
3183
  if (item.kind === "pending") {
2786
3184
  thumbButton = document.createElement("div");
2787
3185
  thumbButton.className = "custom-inpaint-history-generating";
2788
- thumbButton.setAttribute("aria-label", "Image edit generating");
3186
+ thumbButton.setAttribute("aria-label", "Generating");
2789
3187
  thumbButton.setAttribute("role", "img");
2790
3188
  } else {
2791
3189
  thumbButton = document.createElement("button");
2792
3190
  thumbButton.type = "button";
2793
3191
  thumbButton.className = "custom-inpaint-history-thumb-button";
2794
3192
  thumbButton.dataset.historyPreview = item.key;
2795
- thumbButton.setAttribute("aria-label", "View inpaint output");
2796
- const thumb = document.createElement("img");
2797
- thumb.className = "custom-inpaint-history-thumb";
2798
- thumb.src = item.url;
2799
- thumb.alt = "Image edit output";
2800
- thumb.loading = "lazy";
2801
- thumbButton.append(thumb);
3193
+ thumbButton.setAttribute("aria-label", "Jump to this message");
3194
+ if (item.mediaKind === "video") {
3195
+ const badge = document.createElement("span");
3196
+ badge.className = "custom-inpaint-history-thumb custom-inpaint-history-videothumb";
3197
+ badge.textContent = "";
3198
+ thumbButton.append(badge);
3199
+ } else {
3200
+ const thumb = document.createElement("img");
3201
+ thumb.className = "custom-inpaint-history-thumb";
3202
+ thumb.src = item.url;
3203
+ thumb.alt = "";
3204
+ thumb.loading = "lazy";
3205
+ thumbButton.append(thumb);
3206
+ }
2802
3207
  }
2803
3208
  const meta = document.createElement("div");
2804
3209
  meta.className = "custom-inpaint-history-meta";
2805
3210
  const title = document.createElement("div");
2806
3211
  title.className = "custom-inpaint-history-title";
2807
- title.textContent = item.kind === "pending"
2808
- ? "Generating inpaint"
2809
- : item.kind === "source"
2810
- ? (item.origin === "generated" ? "Generated starting image"
2811
- : item.origin === "upload" ? "Uploaded starting image"
2812
- : "Starting image")
2813
- : (item.createdAt ? new Date(item.createdAt).toLocaleString() : "Image edit output");
2814
- if (item.kind === "pending") {
2815
- const subtitle = document.createElement("div");
2816
- subtitle.className = "custom-inpaint-history-subtitle";
2817
- const progress = Number(item.progress || 0);
2818
- subtitle.textContent = "Job " + (item.status || "queued") + (progress > 0 ? " · " + Math.round(progress * 100) + "%" : "");
2819
- meta.append(title, subtitle);
2820
- } else if (item.kind === "source") {
2821
- const subtitle = document.createElement("div");
2822
- subtitle.className = "custom-inpaint-history-subtitle";
2823
- subtitle.textContent = "Where you started" + (item.createdAt ? " · " + new Date(item.createdAt).toLocaleString() : "");
2824
- meta.append(title, subtitle);
2825
- } else {
2826
- meta.append(title);
2827
- }
3212
+ title.textContent = item.title;
3213
+ title.title = item.title;
3214
+ const subtitle = document.createElement("div");
3215
+ subtitle.className = "custom-inpaint-history-subtitle";
3216
+ subtitle.textContent = item.kind === "pending"
3217
+ ? (item.status || "Working…")
3218
+ : (item.mediaKind === "video" ? "Video" : "Image") + (item.createdAt ? " · " + relTimeShort(item.createdAt) : "");
3219
+ meta.append(title, subtitle);
2828
3220
  const actions = document.createElement("div");
2829
3221
  actions.className = "custom-inpaint-history-actions";
2830
3222
  if (item.kind === "pending") {
2831
- const check = document.createElement("button");
2832
- check.type = "button";
2833
- check.className = "custom-inpaint-history-check";
2834
- check.dataset.historyCheck = item.jobId || "";
2835
- check.textContent = "Check Status";
2836
- actions.append(check);
3223
+ if (item.jobId) {
3224
+ const check = document.createElement("button");
3225
+ check.type = "button";
3226
+ check.className = "custom-inpaint-history-check";
3227
+ check.dataset.historyCheck = item.jobId;
3228
+ check.textContent = "Check Status";
3229
+ actions.append(check);
3230
+ }
2837
3231
  } else {
2838
3232
  const download = document.createElement("a");
2839
3233
  download.className = "custom-inpaint-history-icon";
2840
3234
  download.href = item.url;
2841
3235
  download.target = "_blank";
2842
3236
  download.rel = "noopener noreferrer";
2843
- download.download = imageDownloadName("inpaint-history", item.createdAt);
2844
- download.setAttribute("aria-label", "Download inpaint output");
3237
+ download.download = imageDownloadName(item.mediaKind === "video" ? "vidfarm-video" : "vidfarm-image", item.createdAt);
3238
+ download.setAttribute("aria-label", "Download");
2845
3239
  download.setAttribute("title", "Download");
2846
3240
  download.innerHTML = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3v9"></path><path d="m6.5 8.5 3.5 3.5 3.5-3.5"></path><path d="M4 16h12"></path></svg>';
2847
3241
  const copy = document.createElement("button");
2848
3242
  copy.type = "button";
2849
3243
  copy.className = "custom-inpaint-history-icon";
2850
3244
  copy.dataset.historyCopy = item.key;
2851
- copy.setAttribute("aria-label", "Copy inpaint output URL");
3245
+ copy.setAttribute("aria-label", "Copy the file URL");
2852
3246
  copy.setAttribute("title", "Copy URL");
2853
3247
  copy.innerHTML = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1"></path><path d="M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2"></path></svg>';
2854
3248
  const save = document.createElement("button");
2855
3249
  save.type = "button";
2856
3250
  save.className = "custom-inpaint-history-icon";
2857
3251
  save.dataset.historySave = item.key;
2858
- save.setAttribute("aria-label", "Save this output to My Files");
2859
- save.setAttribute("title", "Save to Files");
3252
+ save.setAttribute("aria-label", "Save to My Files");
3253
+ save.setAttribute("title", "Save to My Files");
2860
3254
  save.innerHTML = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 4h8l2 2v10H5z"></path><path d="M8 4v4h4V4"></path><path d="M8 12h4"></path></svg>';
2861
3255
  actions.append(download, copy, save);
2862
3256
  }
2863
- const remove = document.createElement("button");
2864
- remove.type = "button";
2865
- remove.className = "custom-inpaint-history-icon";
2866
- remove.dataset.historyRemove = item.key;
2867
- remove.setAttribute("aria-label", "Remove inpaint output from history strip");
2868
- remove.setAttribute("title", "Remove");
2869
- remove.innerHTML = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 5l10 10"></path><path d="M15 5 5 15"></path></svg>';
2870
- actions.append(remove);
2871
3257
  meta.append(actions);
2872
- const confirm = document.createElement("div");
2873
- confirm.className = "custom-inpaint-history-popconfirm";
2874
- confirm.hidden = true;
2875
- confirm.dataset.historyConfirm = item.key;
2876
- const confirmText = document.createElement("p");
2877
- confirmText.textContent = "Remove from this history strip?";
2878
- const confirmActions = document.createElement("div");
2879
- confirmActions.className = "custom-inpaint-history-popconfirm-actions";
2880
- const cancel = document.createElement("button");
2881
- cancel.type = "button";
2882
- cancel.dataset.historyCancel = item.key;
2883
- cancel.textContent = "Cancel";
2884
- const confirmRemove = document.createElement("button");
2885
- confirmRemove.type = "button";
2886
- confirmRemove.dataset.historyConfirmRemove = item.key;
2887
- confirmRemove.dataset.danger = "true";
2888
- confirmRemove.textContent = "Remove";
2889
- confirmActions.append(cancel, confirmRemove);
2890
- confirm.append(confirmText, confirmActions);
2891
- row.append(thumbButton, meta, confirm);
3258
+ row.append(thumbButton, meta);
2892
3259
  historyList.append(row);
2893
3260
  }
2894
3261
  }
2895
3262
 
2896
- function closeInpaintHistoryConfirms(exceptKey) {
2897
- historyList.querySelectorAll("[data-history-confirm]").forEach((entry) => {
2898
- if (!(entry instanceof HTMLElement)) return;
2899
- entry.hidden = exceptKey ? entry.dataset.historyConfirm !== exceptKey : true;
2900
- });
2901
- }
2902
-
2903
- function extractInpaintHistoryItems(entries) {
2904
- const items = [];
2905
- const seen = new Set();
2906
- for (const entry of Array.isArray(entries) ? entries : []) {
2907
- if (!isInpaintHistoryEntry(entry)) continue;
2908
- const linkedJob = entry?.linkedJob || null;
2909
- if (linkedJob && isPendingJobStatus(linkedJob.status)) {
2910
- const pending = makePendingInpaintHistoryItem({
2911
- job_id: linkedJob.job_id,
2912
- status: linkedJob.status,
2913
- progress: linkedJob.progress,
2914
- created_at: linkedJob.created_at || entry.createdAt,
2915
- updated_at: linkedJob.updated_at || entry.createdAt
2916
- });
2917
- if (pending && !seen.has(pending.key)) {
2918
- seen.add(pending.key);
2919
- items.push(pending);
2920
- }
2921
- continue;
2922
- }
2923
- historyEntryUrls(entry).forEach((rawUrl, index) => {
2924
- const url = absoluteUrl(rawUrl);
2925
- const key = historyItemKey(entry, url, index);
2926
- if (seen.has(key)) return;
2927
- seen.add(key);
2928
- items.push({
2929
- key,
2930
- kind: "output",
2931
- jobId: String(entry?.jobId || entry?.job_id || entry?.linkedJob?.job_id || ""),
2932
- url,
2933
- createdAt: String(entry.createdAt || new Date().toISOString())
2934
- });
2935
- });
2936
- }
2937
- return items;
2938
- }
2939
-
2940
3263
  async function copyText(value) {
2941
3264
  if (navigator.clipboard && window.isSecureContext) {
2942
3265
  try {
@@ -2960,36 +3283,121 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2960
3283
  return copied;
2961
3284
  }
2962
3285
 
2963
- async function loadInpaintHistory() {
3286
+ // ---- session restore -----------------------------------------------------
3287
+ // A session IS its chat. Every turn was a POST to one of the four primitive
3288
+ // routes, and the api-call history stores each request body — including the
3289
+ // the chat_message field we attach purely so the transcript rebuilds verbatim
3290
+ // (the API strips unknown envelope fields, so it never reaches a provider).
3291
+ function userTextFromEntry(entry, kind) {
3292
+ const body = (entry && entry.request && entry.request.body) || {};
3293
+ if (typeof body.chat_message === "string" && body.chat_message.trim()) return body.chat_message.trim();
3294
+ // Fallback for turns made before chat_message existed: recover what we can
3295
+ // from the prompt/instruction we composed.
3296
+ const payload = body.payload || {};
3297
+ const raw = String(payload.prompt || payload.instruction || "");
3298
+ if (!raw) return kind === "video" ? "(video)" : "(edit)";
3299
+ const latest = raw.split("LATEST INSTRUCTION: ").pop();
3300
+ if (latest && latest !== raw) return latest.trim();
3301
+ const edited = raw.split("Edit the SOURCE IMAGE as follows: ")[1];
3302
+ if (edited) return edited.split(" Keep everything else about the source image")[0].trim();
3303
+ const whole = raw.split("Whole image prompt: ")[1];
3304
+ if (whole) return whole.split(". Apply this only as guidance")[0].trim();
3305
+ return raw.length > 240 ? raw.slice(0, 240) + "…" : raw;
3306
+ }
3307
+
3308
+ function turnsFromHistoryEntries(entries) {
3309
+ const rows = (Array.isArray(entries) ? entries : [])
3310
+ .map((entry) => ({ entry, kind: chatEntryKind(entry) }))
3311
+ .filter((row) => row.kind)
3312
+ .sort((a, b) => Date.parse(a.entry.createdAt || 0) - Date.parse(b.entry.createdAt || 0));
3313
+ const turns = [];
3314
+ const seenJobs = new Set();
3315
+ for (const row of rows) {
3316
+ const entry = row.entry;
3317
+ const jobId = String(entry.jobId || (entry.linkedJob && entry.linkedJob.job_id) || "");
3318
+ if (jobId && seenJobs.has(jobId)) continue;
3319
+ if (jobId) seenJobs.add(jobId);
3320
+ const body = (entry.request && entry.request.body) || {};
3321
+ const text = userTextFromEntry(entry, row.kind);
3322
+ const at = String(entry.createdAt || new Date().toISOString());
3323
+ turns.push({
3324
+ id: nextTurnId(), at, role: "user", kind: "text", text,
3325
+ mode: row.kind === "video" ? "video" : "image",
3326
+ masked: row.kind === "inpaint" || body.chat_masked === true,
3327
+ isEdit: row.kind === "edit" || row.kind === "inpaint"
3328
+ });
3329
+ const status = String((entry.linkedJob && entry.linkedJob.status) || "");
3330
+ const urls = row.kind === "video" ? historyEntryVideoUrls(entry) : historyEntryUrls(entry);
3331
+ if (urls.length) {
3332
+ let url = "";
3333
+ try { url = absoluteUrl(urls[0]); } catch { url = String(urls[0]); }
3334
+ turns.push({
3335
+ id: nextTurnId(), at, role: "assistant", kind: "media",
3336
+ mediaKind: row.kind === "video" ? "video" : "image",
3337
+ url, jobId, prompt: text
3338
+ });
3339
+ } else if (status === "failed" || status === "cancelled") {
3340
+ turns.push({ id: nextTurnId(), at, role: "assistant", kind: "error", text: "That job " + status + "." });
3341
+ } else if (isPendingJobStatus(status)) {
3342
+ turns.push({
3343
+ id: nextTurnId(), at, role: "assistant", kind: "pending", jobId, prompt: text,
3344
+ label: "Still running (" + status + ") — use Check Status."
3345
+ });
3346
+ }
3347
+ }
3348
+ return turns;
3349
+ }
3350
+
3351
+ // Rebuild the whole conversation for the active tracer from the server.
3352
+ async function restoreSession(options) {
2964
3353
  const tracer = resolveTracer();
2965
- // Brand-new (unnamed) session: nothing on the server yet — just show any
2966
- // local starting image, without minting a random tracer.
2967
3354
  if (!tracer) {
2968
- inpaintHistoryItems = [];
2969
- reinjectSourceHistoryItems();
2970
- renderInpaintHistory();
3355
+ // Brand-new unnamed session: nothing on the server yet.
3356
+ renderChat();
2971
3357
  return;
2972
3358
  }
3359
+ // Never stomp on a turn that is mid-flight locally.
3360
+ if (isGenBusy() || activeTurnId) { syncSessionAssets(); return; }
2973
3361
  historyRefresh.disabled = true;
2974
3362
  try {
2975
3363
  const endpoint = new URL("/job-runs/history", window.location.origin);
2976
- endpoint.searchParams.set("limit", "120");
2977
- endpoint.searchParams.set("template_id", "primitive:image_inpaint");
3364
+ endpoint.searchParams.set("limit", "250");
2978
3365
  endpoint.searchParams.set("tracer", tracer);
2979
3366
  const response = await fetch(endpoint, { credentials: "same-origin" });
2980
3367
  const payload = await parseJsonResponse(response);
2981
- if (!response.ok || (payload && payload.error)) throw new Error(payload.error || "Unable to load inpaint history.");
2982
- inpaintHistoryItems = extractInpaintHistoryItems(payload.entries);
2983
- reinjectSourceHistoryItems();
2984
- renderInpaintHistory();
3368
+ if (!response.ok || (payload && payload.error)) throw new Error(payload.error || "Unable to load this session.");
3369
+ const turns = turnsFromHistoryEntries(payload.entries);
3370
+ // A refresh must never LOSE messages: run history can lag a job by a moment,
3371
+ // and a fresh upload-only session has nothing on the server at all. Only a
3372
+ // deliberate session switch (replaceEmpty) may shrink the transcript.
3373
+ if (turns.length < chatTurns.length && !(options && options.replaceEmpty)) {
3374
+ renderChat();
3375
+ return;
3376
+ }
3377
+ chatTurns = turns;
3378
+ activeTurnId = "";
3379
+ // The newest IMAGE in the restored conversation becomes the paint surface
3380
+ // and the source of the next edit.
3381
+ let latest = "";
3382
+ for (let i = turns.length - 1; i >= 0; i--) {
3383
+ const turn = turns[i];
3384
+ if (turn.kind === "media" && turn.mediaKind === "image" && turn.url) { latest = turn.url; break; }
3385
+ }
3386
+ if (latest) adoptLatestImage(latest);
3387
+ else renderChat();
2985
3388
  } catch (error) {
2986
3389
  console.error(error);
2987
- setHistoryEmpty(error instanceof Error ? error.message : "Unable to load inpaint history.");
3390
+ setStatus(error instanceof Error ? error.message : "Unable to load this session.", "error");
2988
3391
  } finally {
2989
3392
  historyRefresh.disabled = false;
2990
3393
  }
2991
3394
  }
2992
3395
 
3396
+ // Legacy call sites (job polling, submit) refresh the session through this name.
3397
+ async function loadInpaintHistory() {
3398
+ await restoreSession();
3399
+ }
3400
+
2993
3401
  async function checkInpaintJobStatus(jobId, button) {
2994
3402
  if (!jobId) return null;
2995
3403
  if (button instanceof HTMLButtonElement) {
@@ -3035,6 +3443,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3035
3443
 
3036
3444
  async function pollInpaintJob(jobId) {
3037
3445
  for (let attempt = 0; attempt < 90; attempt += 1) {
3446
+ throwIfStopped();
3038
3447
  const response = await fetch("/api/v1/primitives/image_inpaint/jobs/" + encodeURIComponent(jobId), {
3039
3448
  credentials: "same-origin"
3040
3449
  });
@@ -3051,6 +3460,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3051
3460
  return outputUrl;
3052
3461
  }
3053
3462
  upsertInpaintHistoryItem(makePendingInpaintHistoryItem(payload));
3463
+ updatePendingLabel(activeTurnId, "Applying the masked edit (" + (payload.status || "running") + ")\u2026");
3054
3464
  setStatus("Image edit job " + jobId + " is " + (payload.status || "running") + ".", "");
3055
3465
  await new Promise((resolve) => window.setTimeout(resolve, attempt < 8 ? 1500 : 3000));
3056
3466
  }
@@ -3059,8 +3469,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3059
3469
  return null;
3060
3470
  }
3061
3471
 
3062
- async function submitInpaintJob() {
3063
- if (!sourceImageUrl || inpaintSubmit.disabled) {
3472
+ async function submitInpaintJob(options) {
3473
+ const extraRefs = (options && Array.isArray(options.extraRefs)) ? options.extraRefs : [];
3474
+ // The user's own words, stored alongside the request so the chat can be
3475
+ // rebuilt later without un-parsing the composed instruction.
3476
+ const chatMessage = (options && options.chatMessage) ? String(options.chatMessage) : inpaintGlobalPrompt.value.trim();
3477
+ if (!sourceImageUrl) {
3064
3478
  setStatus("Upload a source image first.", "error");
3065
3479
  return;
3066
3480
  }
@@ -3075,11 +3489,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3075
3489
  setStatus("Add a replacement prompt for " + missingPromptColor.color.toUpperCase() + ".", "error");
3076
3490
  return;
3077
3491
  }
3078
- const instruction = buildInpaintInstruction();
3079
- if (!instruction) {
3492
+ const builtInstruction = buildInpaintInstruction();
3493
+ if (!builtInstruction) {
3080
3494
  setStatus("Add a replacement prompt for at least one color.", "error");
3081
3495
  return;
3082
3496
  }
3497
+ const instruction = builtInstruction;
3083
3498
  setSubmitLoading(true);
3084
3499
  uploadButton.disabled = true;
3085
3500
  setStatus("Uploading masks...", "");
@@ -3095,11 +3510,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3095
3510
  headers: { "content-type": "application/json" },
3096
3511
  body: JSON.stringify({
3097
3512
  tracer,
3513
+ chat_message: chatMessage,
3514
+ chat_mode: "image",
3515
+ chat_masked: true,
3098
3516
  payload: {
3099
3517
  source_image_url: sourceImageUrl,
3100
3518
  mask_url: absoluteUrl(maskUrl),
3101
3519
  instruction,
3102
- reference_attachments: inpaintAttachments.map((attachment) => attachment.url),
3520
+ reference_attachments: inpaintAttachments.map((attachment) => attachment.url).concat(extraRefs),
3103
3521
  ...(inpaintProviderSelect.value ? { provider: inpaintProviderSelect.value } : {}),
3104
3522
  size: "auto",
3105
3523
  ...(getInpaintAspectRatio() ? { aspect_ratio: getInpaintAspectRatio() } : {}),
@@ -3110,13 +3528,19 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3110
3528
  const payload = await parseJsonResponse(response);
3111
3529
  if (!response.ok) throw new Error(payload.error || "Unable to create image edit job.");
3112
3530
  setStatus("Image edit job " + (payload.job_id || "") + " started.", "success");
3531
+ activeJob = { kind: "image_inpaint", jobId: String(payload.job_id || "") };
3113
3532
  upsertInpaintHistoryItem(makePendingInpaintHistoryItem(payload));
3114
3533
  await pollInpaintJob(payload.job_id);
3534
+ if (activeTurnId) {
3535
+ updatePendingLabel(activeTurnId, "Still running \u2014 use Check Status in this session\u2019s history.");
3536
+ activeTurnId = "";
3537
+ }
3115
3538
  await loadInpaintHistory();
3116
3539
  void loadTracerSessions();
3117
3540
  } catch (error) {
3118
3541
  console.error(error);
3119
3542
  setStatus(error instanceof Error ? error.message : "Unable to create image edit.", "error");
3543
+ throw error;
3120
3544
  } finally {
3121
3545
  setSubmitLoading(false);
3122
3546
  // Respect the post-generate lock — don't re-enable source upload if the
@@ -3133,25 +3557,23 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3133
3557
  historyRefresh.addEventListener("click", () => {
3134
3558
  void loadInpaintHistory();
3135
3559
  });
3136
- historyList.addEventListener("pointerdown", (event) => {
3137
- const target = event.target;
3138
- if (target instanceof Element && target.closest("[data-history-confirm]")) {
3139
- event.stopPropagation();
3140
- }
3141
- });
3142
3560
  historyList.addEventListener("click", (event) => {
3143
3561
  const target = event.target;
3144
3562
  if (!(target instanceof Element)) return;
3145
- const confirmPopover = target.closest("[data-history-confirm]");
3146
- if (confirmPopover instanceof HTMLElement) {
3147
- event.stopPropagation();
3148
- }
3563
+ // The rail mirrors the chat, so its thumbnail scrolls the feed to that turn
3564
+ // instead of opening a second, competing viewer.
3149
3565
  const previewButton = target.closest("[data-history-preview]");
3150
3566
  if (previewButton instanceof HTMLElement) {
3151
3567
  const key = previewButton.dataset.historyPreview || "";
3568
+ const row = chatFeed.querySelector('[data-turn-id="' + CSS.escape(key) + '"]');
3569
+ if (row instanceof HTMLElement) {
3570
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
3571
+ row.classList.add("is-flash");
3572
+ window.setTimeout(() => row.classList.remove("is-flash"), 1200);
3573
+ return;
3574
+ }
3152
3575
  const item = inpaintHistoryItems.find((entry) => entry.key === key);
3153
- if (!item) return;
3154
- openImagePreview(item.url, item.createdAt ? new Date(item.createdAt).toLocaleString() : "Image edit output", imageDownloadName("image-edit-history", item.createdAt));
3576
+ if (item && item.url) openImagePreview(item.url, item.title, imageDownloadName("vidfarm-output", item.createdAt), item.mediaKind);
3155
3577
  return;
3156
3578
  }
3157
3579
  const copyButton = target.closest("[data-history-copy]");
@@ -3169,40 +3591,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3169
3591
  if (saveButton instanceof HTMLElement) {
3170
3592
  const key = saveButton.dataset.historySave || "";
3171
3593
  const item = inpaintHistoryItems.find((entry) => entry.key === key);
3172
- if (item && item.url) saveMediaToFiles(item.url, imageDownloadName("inpaint-output", item.createdAt));
3594
+ if (item && item.url) saveMediaToFiles(item.url, imageDownloadName("vidfarm-output", item.createdAt));
3173
3595
  return;
3174
3596
  }
3175
3597
  const checkButton = target.closest("[data-history-check]");
3176
3598
  if (checkButton instanceof HTMLButtonElement) {
3177
3599
  void checkInpaintJobStatus(checkButton.dataset.historyCheck || "", checkButton);
3178
- return;
3179
- }
3180
- const removeButton = target.closest("[data-history-remove]");
3181
- if (removeButton instanceof HTMLElement) {
3182
- const key = removeButton.dataset.historyRemove || "";
3183
- const confirm = historyList.querySelector('[data-history-confirm="' + CSS.escape(key) + '"]');
3184
- const shouldOpen = !(confirm instanceof HTMLElement) || confirm.hidden;
3185
- closeInpaintHistoryConfirms();
3186
- if (confirm instanceof HTMLElement) {
3187
- confirm.hidden = !shouldOpen;
3188
- }
3189
- removeButton.setAttribute("aria-expanded", shouldOpen ? "true" : "false");
3190
- return;
3191
- }
3192
- const cancelButton = target.closest("[data-history-cancel]");
3193
- if (cancelButton instanceof HTMLElement) {
3194
- const confirm = cancelButton.closest("[data-history-confirm]");
3195
- if (confirm instanceof HTMLElement) confirm.hidden = true;
3196
- return;
3197
- }
3198
- const confirmRemove = target.closest("[data-history-confirm-remove]");
3199
- if (confirmRemove instanceof HTMLElement) {
3200
- const key = confirmRemove.dataset.historyConfirmRemove || "";
3201
- if (key) {
3202
- hiddenInpaintHistoryKeys.add(key);
3203
- renderInpaintHistory();
3204
- }
3205
- return;
3206
3600
  }
3207
3601
  });
3208
3602
  resultOpenButton.addEventListener("click", () => {
@@ -3272,18 +3666,22 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3272
3666
  inpaintGlobalPrompt.addEventListener("paste", (event) => {
3273
3667
  handleAttachmentPaste(event, "global");
3274
3668
  });
3275
- inpaintUndo.addEventListener("click", () => {
3669
+ function undoStroke() {
3276
3670
  const stroke = inpaintStrokes.pop();
3277
3671
  if (stroke) redoStrokes.push(stroke);
3278
3672
  redrawInpaintMask();
3673
+ syncMaskUi();
3279
3674
  setStatus("", "");
3280
- });
3281
- inpaintRedo.addEventListener("click", () => {
3675
+ }
3676
+ function redoStroke() {
3282
3677
  const stroke = redoStrokes.pop();
3283
3678
  if (stroke) inpaintStrokes.push(stroke);
3284
3679
  redrawInpaintMask();
3680
+ syncMaskUi();
3285
3681
  setStatus("", "");
3286
- });
3682
+ }
3683
+ inpaintUndo.addEventListener("click", undoStroke);
3684
+ inpaintRedo.addEventListener("click", redoStroke);
3287
3685
  inpaintClear.addEventListener("click", () => {
3288
3686
  resetMask();
3289
3687
  setStatus("", "");
@@ -3349,7 +3747,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3349
3747
  hideBrushCursor();
3350
3748
  });
3351
3749
  inpaintSubmit.addEventListener("click", () => {
3352
- void submitInpaintJob();
3750
+ submitInpaintJob().catch(() => {});
3353
3751
  });
3354
3752
  function openSettingsModal() {
3355
3753
  settingsPopover.hidden = false;
@@ -3366,6 +3764,20 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3366
3764
  if (settingsPopover.hidden) openSettingsModal(); else closeSettingsModal();
3367
3765
  });
3368
3766
  if (settingsClose) settingsClose.addEventListener("click", () => closeSettingsModal());
3767
+
3768
+ // Copy chat ID — the session tracer IS this chat's thread id (it's what
3769
+ // /job-runs/history groups on, what ?tracer= reopens, and what the past-
3770
+ // sessions rail keys off). A still-unnamed session mints its id here so
3771
+ // there is always something concrete to copy/share.
3772
+ const copyChatButton = document.querySelector("[data-custom-inpaint-copy-chat]");
3773
+ if (copyChatButton instanceof HTMLButtonElement) {
3774
+ copyChatButton.addEventListener("click", () => {
3775
+ const tracer = resolveTracer() || getTracerValue();
3776
+ void copyText(tracer).then((copied) => {
3777
+ setStatus(copied ? "Chat ID copied — " + tracer : "Chat ID: " + tracer, copied ? "success" : "");
3778
+ });
3779
+ });
3780
+ }
3369
3781
  settingsPopover.addEventListener("click", (event) => {
3370
3782
  if (event.target === settingsPopover) closeSettingsModal();
3371
3783
  });
@@ -3384,16 +3796,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3384
3796
  if (sessionsBackdrop) sessionsBackdrop.hidden = true;
3385
3797
  if (settingsPopover.hidden) document.body.style.overflow = "";
3386
3798
  }
3387
- function isImageSessionEntry(entry) {
3388
- const templateId = String(entry && entry.linkedJob && (entry.linkedJob.template_id || entry.linkedJob.templateId) || "").toLowerCase();
3389
- const path = String(entry && entry.path || "").toLowerCase();
3390
- return templateId.includes("image_inpaint")
3391
- || templateId.includes("image_generate")
3392
- || path.includes("/primitives/images/inpaint")
3393
- || path.includes("/primitives/image_inpaint")
3394
- || path.includes("/primitives/images/generate")
3395
- || path.includes("/primitives/image_generate");
3396
- }
3397
3799
  function relTimeShort(value) {
3398
3800
  const then = Date.parse(value || 0);
3399
3801
  if (!then) return "";
@@ -3454,13 +3856,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3454
3856
  meta.className = "rk-ip-session-meta";
3455
3857
  const title = document.createElement("div");
3456
3858
  title.className = "rk-ip-session-title";
3457
- title.textContent = isImage ? (group.name || "Standalone image") : group.tracer;
3859
+ title.textContent = isImage ? (group.name || "Standalone image") : (group.title || group.tracer);
3860
+ title.title = isImage ? "" : (group.title ? group.title + " (" + group.tracer + ")" : group.tracer);
3458
3861
  const sub = document.createElement("div");
3459
3862
  sub.className = "rk-ip-session-sub";
3460
3863
  const when = relTimeShort(group.createdAt);
3461
3864
  sub.textContent = isImage
3462
- ? "Standalone image — tap to edit" + (when ? " · " + when : "")
3463
- : (group.count ? group.count + " image" + (group.count === 1 ? "" : "s") : "session") + (when ? " · " + when : "");
3865
+ ? "Standalone image — tap to start a chat from it" + (when ? " · " + when : "")
3866
+ : (group.count ? group.count + " message" + (group.count === 1 ? "" : "s") : "session") + (when ? " · " + when : "");
3464
3867
  meta.append(title, sub);
3465
3868
  row.append(meta);
3466
3869
  sessionsList.append(row);
@@ -3477,16 +3880,17 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3477
3880
  const payload = await parseJsonResponse(response);
3478
3881
  if (!response.ok || (payload && payload.error)) throw new Error((payload && payload.error) || "Unable to load sessions.");
3479
3882
  const groups = new Map();
3480
- // Past image gens that carry NO session tracer (e.g. one-off generations
3481
- // from other surfaces) can't be reopened as a session, so surface each as
3482
- // a standalone image the user can load fresh into a new inpaint session.
3883
+ // Past one-off image gens with NO session tracer can't be reopened as a
3884
+ // conversation, so each is offered as a standalone image to start one from.
3483
3885
  const loose = [];
3484
3886
  const looseSeen = new Set();
3485
3887
  for (const entry of Array.isArray(payload.entries) ? payload.entries : []) {
3486
- if (!isImageSessionEntry(entry)) continue;
3487
- const urls = historyEntryUrls(entry);
3888
+ const kind = chatEntryKind(entry);
3889
+ if (!kind) continue;
3890
+ const urls = kind === "video" ? historyEntryVideoUrls(entry) : historyEntryUrls(entry);
3488
3891
  const tracer = String(entry.tracer || "").trim();
3489
3892
  if (!tracer) {
3893
+ if (kind === "video") continue;
3490
3894
  for (const raw of urls) {
3491
3895
  let url = "";
3492
3896
  try { url = absoluteUrl(raw); } catch { continue; }
@@ -3497,9 +3901,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3497
3901
  continue;
3498
3902
  }
3499
3903
  let group = groups.get(tracer);
3500
- if (!group) { group = { kind: "session", tracer, thumb: "", count: 0, createdAt: entry.createdAt || "" }; groups.set(tracer, group); }
3501
- if (!group.thumb && urls[0]) { try { group.thumb = absoluteUrl(urls[0]); } catch {} }
3502
- group.count += urls.length;
3904
+ if (!group) { group = { kind: "session", tracer, thumb: "", title: "", count: 0, createdAt: entry.createdAt || "", firstAt: entry.createdAt || "" }; groups.set(tracer, group); }
3905
+ group.count += 1;
3906
+ // Newest image in the session is its cover; oldest message is its name.
3907
+ if (kind !== "video" && urls[0] && Date.parse(entry.createdAt || 0) >= Date.parse(group.createdAt || 0)) {
3908
+ try { group.thumb = absoluteUrl(urls[0]); } catch {}
3909
+ }
3910
+ if (!group.title || Date.parse(entry.createdAt || 0) <= Date.parse(group.firstAt || 0)) {
3911
+ const text = userTextFromEntry(entry, kind);
3912
+ if (text) { group.title = text.length > 60 ? text.slice(0, 60) + "…" : text; group.firstAt = entry.createdAt || group.firstAt; }
3913
+ }
3503
3914
  if (Date.parse(entry.createdAt || 0) > Date.parse(group.createdAt || 0)) group.createdAt = entry.createdAt;
3504
3915
  }
3505
3916
  const list = Array.from(groups.values()).concat(loose)
@@ -3512,7 +3923,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3512
3923
  sessionsLoading = false;
3513
3924
  }
3514
3925
  }
3515
- function switchToTracer(tracer, sourceUrl) {
3926
+ async function switchToTracer(tracer) {
3516
3927
  const value = String(tracer || "").trim();
3517
3928
  if (!value) return;
3518
3929
  clearInpaintUrlParams();
@@ -3521,16 +3932,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3521
3932
  tracerInput.value = value;
3522
3933
  tracerAutoPending = false;
3523
3934
  closeSessionsPanel();
3524
- void loadInpaintHistory();
3935
+ setStatus("Opening " + value + "…", "");
3936
+ // Rebuild the whole conversation — messages, edits, outputs — and put the
3937
+ // newest image back under the brush.
3938
+ await restoreSession({ replaceEmpty: true });
3525
3939
  void loadTracerSessions();
3526
- // Load this session's latest image straight onto the canvas so it's ready
3527
- // to paint/refinereopening a session with a blank stage is useless.
3528
- if (sourceUrl) {
3529
- loadSourceUrl(sourceUrl, "history");
3530
- setStatus("Opened session " + value + " — paint mask regions to keep editing.", "success");
3531
- } else {
3532
- setStatus("Opened session " + value + ".", "");
3533
- }
3940
+ setStatus(chatTurns.length
3941
+ ? "Session restoredkeep chatting to pick up where you left off."
3942
+ : "Opened " + value + " — no messages in it yet.", chatTurns.length ? "success" : "");
3534
3943
  }
3535
3944
  // A past image gen with no session tracer: start a brand-new session and drop
3536
3945
  // that image onto the editor as the source so it can be edited immediately.
@@ -3545,7 +3954,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3545
3954
  closeSessionsPanel();
3546
3955
  loadSourceUrl(value, "history");
3547
3956
  void loadTracerSessions();
3548
- setStatus("New session started from that image — paint mask regions to edit it.", "success");
3957
+ setStatus("New session started from that image — describe an edit, or paint a mask first.", "success");
3549
3958
  }
3550
3959
  if (sessionsToggle) sessionsToggle.addEventListener("click", () => {
3551
3960
  if (sessionsPanel && sessionsPanel.classList.contains("is-open")) closeSessionsPanel(); else openSessionsPanel();
@@ -3562,19 +3971,10 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3562
3971
  }
3563
3972
  const row = target.closest("[data-session-tracer]");
3564
3973
  if (row instanceof HTMLElement && row.dataset.sessionTracer) {
3565
- const thumb = row.querySelector("img.rk-ip-session-thumb");
3566
- switchToTracer(row.dataset.sessionTracer, thumb instanceof HTMLImageElement ? thumb.src : "");
3974
+ void switchToTracer(row.dataset.sessionTracer);
3567
3975
  }
3568
3976
  });
3569
3977
 
3570
- window.addEventListener("click", (event) => {
3571
- const target = event.target;
3572
- if (!(target instanceof Node)) return;
3573
- const isHistoryConfirmTarget = target instanceof Element && Boolean(target.closest("[data-history-confirm]"));
3574
- if (!historyList.contains(target) && !isHistoryConfirmTarget) {
3575
- closeInpaintHistoryConfirms();
3576
- }
3577
- });
3578
3978
  window.addEventListener("resize", () => syncInpaintCanvasSize());
3579
3979
  window.addEventListener("keydown", (event) => {
3580
3980
  if (event.key !== "Escape") return;
@@ -3583,153 +3983,71 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3583
3983
  if (sessionsPanel && sessionsPanel.classList.contains("is-open")) { closeSessionsPanel(); return; }
3584
3984
  });
3585
3985
 
3586
- // ===== text-to-image "Create Image" empty-state composer =====================
3986
+ // ===== chat composer + feed ================================================
3587
3987
  // Layered on top of the legacy inpaint logic; reuses its in-scope helpers
3588
- // (setStatus, loadSourceUrl, uploadTemporaryBlob, getTracerValue, parseJsonResponse,
3589
- // absoluteUrl, extractOutputUrl, inpaintProviderSelect). A generated image becomes
3590
- // the editable SOURCE so you can immediately paint to refine it.
3988
+ // (setStatus, uploadTemporaryBlob, getTracerValue, parseJsonResponse,
3989
+ // absoluteUrl, extractOutputUrl, inpaintProviderSelect, submitInpaintJob).
3990
+ // Every finished image becomes the newest image, which is both the paint
3991
+ // surface and the source the NEXT message edits.
3591
3992
  var genPrompt = document.getElementById('rk-ip-genprompt');
3592
3993
  var genClip = document.getElementById('rk-ip-genclip');
3593
3994
  var genSend = document.getElementById('rk-ip-gensend');
3594
3995
  var genRefsEl = document.getElementById('rk-ip-genrefs');
3595
3996
  var genStatusEl = document.getElementById('rk-ip-genstatus');
3596
- var genWrap = document.getElementById('rk-ip-gen');
3597
- var GEN_SEND_HTML = genSend ? genSend.innerHTML : '';
3598
- var genRefs = [];
3599
- var genBusy = false;
3600
-
3601
- function setGenStatus(m, t){ if(!genStatusEl) return; genStatusEl.textContent = m || ''; genStatusEl.setAttribute('data-tone', t || ''); }
3602
- function canGenNew(){ return Boolean((genPrompt && genPrompt.value || '').trim()) && !genBusy; }
3603
- function syncGenSend(){
3604
- if(genPrompt) genPrompt.disabled = genBusy;
3605
- if(genClip) genClip.disabled = genBusy;
3606
- if(!genSend) return;
3607
- genSend.disabled = !canGenNew();
3608
- genSend.classList.toggle('is-busy', genBusy);
3609
- genSend.innerHTML = genBusy ? '<span class="rk-ip-gen-spin" aria-hidden="true"></span>' : GEN_SEND_HTML;
3610
- }
3611
- function renderGenRefs(){
3612
- if(!genRefsEl) return;
3613
- genRefsEl.hidden = genRefs.length === 0;
3614
- genRefsEl.innerHTML = '';
3615
- genRefs.forEach(function(url, idx){
3616
- var chip = document.createElement('span'); chip.className = 'rk-ip-gen-ref';
3617
- var im = document.createElement('img'); im.src = url; im.alt = ''; im.loading = 'lazy'; chip.appendChild(im);
3618
- var lbl = document.createElement('span'); var nm = url.split('/').pop() || 'reference'; lbl.textContent = decodeURIComponent(nm).slice(0, 28); chip.appendChild(lbl);
3619
- var x = document.createElement('button'); x.type = 'button'; x.setAttribute('aria-label', 'Remove reference'); x.innerHTML = '&times;';
3620
- x.addEventListener('click', function(){ genRefs.splice(idx, 1); renderGenRefs(); }); chip.appendChild(x);
3621
- genRefsEl.appendChild(chip);
3622
- });
3997
+ // The composer itself — auto-grow, the drag-to-resize grip, reference chips,
3998
+ // paste/drop, and the attach pop panel — is the shared implementation from
3999
+ // chat-composer.ts, byte-identical to the one on /tools/video.
4000
+ var composer = window.__rkChatComposer({
4001
+ root: composerEl,
4002
+ textarea: genPrompt,
4003
+ refsEl: genRefsEl,
4004
+ sendBtn: genSend,
4005
+ clipBtn: genClip,
4006
+ attachTitle: "Attach reference images",
4007
+ uploadBlob: function(f, name, type){ return uploadTemporaryBlob(f, name, type); },
4008
+ onStatus: function(m, t){ setStatus(m, t); },
4009
+ canSend: function(){ return maskHasInstruction(); },
4010
+ onSend: function(){ sendChatMessage(); },
4011
+ onStop: function(){ stopActiveTurn(); }
4012
+ });
4013
+ // Aliases so the rest of the page keeps its original call sites.
4014
+ function isGenBusy(){ return Boolean(composer && composer.isBusy()); }
4015
+ function renderGenRefs(){ composer.renderRefs(); }
4016
+ function autoGrowComposer(){ composer.autoGrow(); }
4017
+ function addGenReferenceFiles(fl){ return composer.addFiles(fl); }
4018
+ function closeGenFilePanel(){ composer.closePanel(); }
4019
+ function syncGenSend(){ composer.syncSend(); }
4020
+ function setGenBusy(v){ composer.setBusy(v); }
4021
+
4022
+ // One status line for the whole surface — the composer's.
4023
+ function setGenStatus(m, t){ setStatus(m, t); }
4024
+ function maskHasInstruction(){
4025
+ return inpaintStrokes.length > 0 && inpaintMaskColors.some(function(entry){ return String(entry.prompt || '').trim(); });
3623
4026
  }
3624
4027
 
3625
- // Paperclip -> shared file-directory explorer (pop panel) to attach image refs.
3626
- var genFileModal = null;
3627
- function closeGenFilePanel(){ if(genFileModal){ try{ genFileModal._handle && genFileModal._handle.destroy && genFileModal._handle.destroy(); }catch(_){} genFileModal.remove(); genFileModal = null; document.removeEventListener('keydown', onGenFileEsc, true); } }
3628
- function onGenFileEsc(e){ if(e.key === 'Escape') closeGenFilePanel(); }
3629
- function openGenFilePanel(){
3630
- if(genFileModal) return;
3631
- var overlay = document.createElement('div'); overlay.className = 'rk-ip-filemodal';
3632
- var panel = document.createElement('div'); panel.className = 'rk-ip-filemodal-panel';
3633
- var head = document.createElement('div'); head.className = 'rk-ip-filemodal-head';
3634
- var title = document.createElement('span'); title.className = 'rk-ip-filemodal-title'; title.textContent = 'Attach reference images';
3635
- var headActions = document.createElement('div'); headActions.className = 'rk-ip-filemodal-actions';
3636
- var uploadBtn = document.createElement('button'); uploadBtn.type = 'button'; uploadBtn.className = 'rk-ip-filemodal-upload'; uploadBtn.textContent = '\u2191 Upload from computer';
3637
- var uploadInput = document.createElement('input'); uploadInput.type = 'file'; uploadInput.accept = 'image/png,image/jpeg,image/webp,image/gif,image/avif'; uploadInput.multiple = true; uploadInput.hidden = true;
3638
- var close = document.createElement('button'); close.type = 'button'; close.className = 'rk-ip-filemodal-close'; close.setAttribute('aria-label', 'Close'); close.innerHTML = '&times;';
3639
- headActions.appendChild(uploadBtn); headActions.appendChild(close);
3640
- head.appendChild(title); head.appendChild(headActions); head.appendChild(uploadInput);
3641
- uploadBtn.addEventListener('click', function(){ if(!uploadBtn.disabled) uploadInput.click(); });
3642
- uploadInput.addEventListener('change', function(){
3643
- var files = Array.prototype.slice.call(uploadInput.files || []).filter(function(f){ return /^image\//i.test(f.type || '') || /\.(png|jpe?g|gif|webp|avif)$/i.test(f.name || ''); });
3644
- uploadInput.value = '';
3645
- if(!files.length){ setGenStatus('Pick an image file to upload as a reference.', 'error'); return; }
3646
- uploadBtn.disabled = true; var original = uploadBtn.textContent; uploadBtn.textContent = 'Uploading\u2026';
3647
- setGenStatus('Uploading ' + files.length + ' reference' + (files.length > 1 ? 's' : '') + '\u2026', '');
3648
- var added = 0;
3649
- files.reduce(function(chain, f){
3650
- return chain.then(function(){
3651
- return uploadTemporaryBlob(f, f.name || 'reference.png', f.type || 'image/png').then(function(url){
3652
- if(url && genRefs.indexOf(url) === -1){ genRefs.push(url); added++; renderGenRefs(); }
3653
- });
3654
- });
3655
- }, Promise.resolve()).then(function(){
3656
- uploadBtn.disabled = false; uploadBtn.textContent = original;
3657
- if(added){ setGenStatus(added + ' reference' + (added > 1 ? 's' : '') + ' uploaded.', 'success'); closeGenFilePanel(); }
3658
- else setGenStatus('Those files were already attached.', '');
3659
- }).catch(function(err){
3660
- uploadBtn.disabled = false; uploadBtn.textContent = original;
3661
- setGenStatus((err && err.message) ? err.message : 'Upload failed.', 'error');
3662
- });
3663
- });
3664
- var mountEl = document.createElement('div'); mountEl.className = 'rk-ip-filemodal-mount';
3665
- panel.appendChild(head); panel.appendChild(mountEl); overlay.appendChild(panel);
3666
- document.body.appendChild(overlay); genFileModal = overlay;
3667
- close.addEventListener('click', closeGenFilePanel);
3668
- overlay.addEventListener('click', function(e){ if(e.target === overlay) closeGenFilePanel(); });
3669
- document.addEventListener('keydown', onGenFileEsc, true);
3670
- if(window.__vidfarmDirectory && window.__vidfarmDirectory.mount){
3671
- genFileModal._handle = window.__vidfarmDirectory.mount(mountEl, {
3672
- mode: 'attach', showMultiSelect: true, primaryClick: 'attach',
3673
- onAttach: function(items){
3674
- var added = 0;
3675
- (items || []).forEach(function(it){
3676
- var ct = (it.contentType || ''); var isImg = ct.indexOf('image/') === 0 || /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(it.name || '');
3677
- if(it.viewUrl && isImg && genRefs.indexOf(it.viewUrl) === -1){ genRefs.push(it.viewUrl); added++; }
3678
- });
3679
- renderGenRefs();
3680
- if(added){ setGenStatus(added + ' reference' + (added > 1 ? 's' : '') + ' attached.', 'success'); closeGenFilePanel(); }
3681
- else setGenStatus('Pick an image file to use as a reference.', 'error');
3682
- }
3683
- });
3684
- } else {
3685
- mountEl.innerHTML = '<p style="padding:16px;color:var(--rk-text-muted);font-size:13px">File browser is unavailable right now.</p>';
4028
+ // ---- stop --------------------------------------------------------------
4029
+ // Stop aborts the client poll AND best-effort cancels the job server-side so a
4030
+ // long video render stops burning provider time.
4031
+ var activeJob = { kind: '', jobId: '' };
4032
+ var stopRequested = false;
4033
+ function stopError(){ var e = new Error('__vidfarm_stopped__'); e.stopped = true; return e; }
4034
+ function throwIfStopped(){ if(stopRequested) throw stopError(); }
4035
+ function stopActiveTurn(){
4036
+ if(!isGenBusy() || stopRequested) return;
4037
+ stopRequested = true;
4038
+ setStatus('Stopping…', '');
4039
+ if(activeTurnId) updatePendingLabel(activeTurnId, 'Stopping…');
4040
+ if(activeJob.kind && activeJob.jobId){
4041
+ fetch('/api/v1/primitives/' + activeJob.kind + '/jobs/' + encodeURIComponent(activeJob.jobId) + '/cancel', {
4042
+ method: 'POST', credentials: 'same-origin'
4043
+ }).catch(function(){});
3686
4044
  }
3687
4045
  }
3688
-
3689
- // Paste or drop image files straight into the composer -> temp upload -> reference.
3690
- function addGenReferenceFiles(fileList){
3691
- var files = Array.prototype.slice.call(fileList || []).filter(function(f){ return /^image\//i.test(f.type || '') || /\.(png|jpe?g|gif|webp|avif)$/i.test(f.name || ''); });
3692
- if(!files.length) return Promise.resolve(0);
3693
- setGenStatus('Uploading ' + files.length + ' reference' + (files.length > 1 ? 's' : '') + '\u2026', '');
3694
- var added = 0;
3695
- return files.reduce(function(chain, f){
3696
- return chain.then(function(){
3697
- return uploadTemporaryBlob(f, f.name || 'pasted-image.png', f.type || 'image/png').then(function(url){
3698
- if(url && genRefs.indexOf(url) === -1){ genRefs.push(url); added++; renderGenRefs(); }
3699
- });
3700
- });
3701
- }, Promise.resolve()).then(function(){
3702
- if(added) setGenStatus(added + ' reference' + (added > 1 ? 's' : '') + ' attached.', 'success');
3703
- return added;
3704
- }).catch(function(err){ setGenStatus((err && err.message) ? err.message : 'Upload failed.', 'error'); return added; });
3705
- }
3706
- function imagesFromClipboard(e){
3707
- var items = (e.clipboardData && e.clipboardData.items) || []; var out = [];
3708
- for(var i = 0; i < items.length; i++){ if(items[i].kind === 'file'){ var f = items[i].getAsFile(); if(f && /^image\//i.test(f.type || '')) out.push(f); } }
3709
- return out;
3710
- }
3711
- function dtHasFiles(e){ var t = (e.dataTransfer && e.dataTransfer.types) || []; return Array.prototype.indexOf.call(t, 'Files') !== -1; }
3712
-
3713
- function pollImageGenerateJob(jobId){
3714
- var attempt = 0;
3715
- function step(){
3716
- return fetch('/api/v1/primitives/image_generate/jobs/' + encodeURIComponent(jobId), { credentials: 'same-origin' }).then(function(r){
3717
- return parseJsonResponse(r).then(function(payload){
3718
- if(!r.ok) throw new Error(payload.error || 'Unable to poll image job.');
3719
- if(payload.status === 'failed' || payload.status === 'cancelled'){ throw new Error((payload.error && payload.error.message) || ('Image job ' + payload.status + '.')); }
3720
- var out = extractOutputUrl(payload);
3721
- if(payload.status === 'succeeded' || payload.status === 'complete' || out){
3722
- if(!out) throw new Error('Image job completed without an output URL.');
3723
- return absoluteUrl(out);
3724
- }
3725
- setGenStatus('Generating image (' + (payload.status || 'running') + ')\u2026', '');
3726
- attempt++;
3727
- if(attempt >= 90){ setGenStatus('Still running \u2014 check back shortly.', ''); return ''; }
3728
- return new Promise(function(res){ setTimeout(res, attempt < 8 ? 1500 : 3000); }).then(step);
3729
- });
3730
- });
3731
- }
3732
- return step();
4046
+ function composerPlaceholder(){
4047
+ if(!genPrompt) return;
4048
+ genPrompt.placeholder = sourceImageUrl
4049
+ ? (inpaintStrokes.length ? 'Describe the masked edit (whole-image guidance)\u2026' : 'Describe the next edit to the newest image\u2026')
4050
+ : 'Describe the image you want\u2026';
3733
4051
  }
3734
4052
 
3735
4053
  // "character card" / "storyboard" shorthand — spell out the full layout
@@ -3765,47 +4083,380 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3765
4083
  btn.addEventListener('click', function(){ applyGenPreset(btn.getAttribute('data-preset') || 'character-card'); });
3766
4084
  });
3767
4085
 
3768
- function submitGenerate(){
3769
- var p = expandShorthand((genPrompt && genPrompt.value || '').trim());
3770
- if(!p){ setGenStatus('Type a prompt to generate an image.', 'error'); if(genPrompt) genPrompt.focus(); return; }
3771
- if(genBusy) return;
3772
- genBusy = true; syncGenSend(); if(genWrap) genWrap.classList.add('is-busy');
3773
- setGenStatus('Creating image\u2026', '');
3774
- var payload = { prompt: p, output_format: 'png' };
3775
- if(genRefs.length) payload.prompt_attachments = genRefs.slice();
3776
- if(inpaintProviderSelect && inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
3777
- var genTracer = tracerAutoPending ? nameTracerFromSeed(p) : getTracerValue();
3778
- if(tracerAutoPending === false){ void loadTracerSessions(); }
3779
- fetch('/api/v1/primitives/images/generate', {
3780
- method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
3781
- body: JSON.stringify({ tracer: genTracer, payload: payload })
3782
- }).then(parseJsonResponse).then(function(j){
3783
- if(!j || !j.job_id){ throw new Error((j && j.error) || 'Unable to start image generation.'); }
3784
- setGenStatus('Generating image\u2026', '');
3785
- return pollImageGenerateJob(j.job_id);
3786
- }).then(function(out){
3787
- if(out){ loadSourceUrl(out, 'generated'); setStatus('Generated image ready. Paint mask regions to refine, or download it.', 'success'); }
3788
- }).catch(function(err){
3789
- var msg = (err && err.message) ? err.message : 'Unable to generate image.';
3790
- if(/401|403|sign in|unauth/i.test(msg)) msg = 'Sign in to generate images.';
3791
- setGenStatus(msg, 'error');
3792
- }).then(function(){ genBusy = false; if(genWrap) genWrap.classList.remove('is-busy'); syncGenSend(); });
4086
+ // ---- chat state ----------------------------------------------------------
4087
+ // chatTurns/activeTurnId/renderChat/addTurn/adoptLatestImage are declared with
4088
+ // var + function declarations so the earlier legacy helpers (loadSourceUrl,
4089
+ // applyCompletedInpaintJob, resetWorkspace) can call them safely.
4090
+ var turnSeq = 0;
4091
+ function nextTurnId(){ turnSeq += 1; return 't' + turnSeq; }
4092
+ function addTurn(turn){
4093
+ var item = Object.assign({ id: nextTurnId(), at: new Date().toISOString(), role: 'assistant' }, turn);
4094
+ chatTurns.push(item);
4095
+ renderChat();
4096
+ return item;
4097
+ }
4098
+ function updateTurn(id, patch){
4099
+ if(!id) return;
4100
+ chatTurns = chatTurns.map(function(t){ return t.id === id ? Object.assign({}, t, patch) : t; });
4101
+ renderChat();
4102
+ }
4103
+ // Pending labels tick every poll; patch the node in place so a full re-render
4104
+ // never restarts a video that is playing further up the feed.
4105
+ function updatePendingLabel(id, label){
4106
+ if(!id) return;
4107
+ chatTurns = chatTurns.map(function(t){ return t.id === id ? Object.assign({}, t, { label: label }) : t; });
4108
+ var node = chatFeed.querySelector('[data-pending-id="' + id + '"]');
4109
+ if(node) node.textContent = label;
4110
+ }
4111
+ function resolveActiveTurn(patch){
4112
+ if(activeTurnId){ updateTurn(activeTurnId, patch); activeTurnId = ''; return; }
4113
+ addTurn(Object.assign({ role: 'assistant' }, patch));
4114
+ }
4115
+
4116
+ // Make the given url the newest image: the paint surface AND the source of the next edit.
4117
+ function adoptLatestImage(url, name){
4118
+ if(!url) return;
4119
+ sourceImageUrl = url;
4120
+ sourceImageName = name || sourceNameFromUrl(url);
4121
+ sourceName.textContent = sourceImageName;
4122
+ canvasWrap.hidden = false;
4123
+ outputPanel.hidden = true;
4124
+ inpaintImage.onload = function(){ syncInpaintCanvasSize(); resetMask(); };
4125
+ inpaintImage.src = url;
4126
+ resetMask();
4127
+ renderChat();
4128
+ composerPlaceholder();
4129
+ }
4130
+
4131
+ // ---- feed rendering ------------------------------------------------------
4132
+ function mediaActionsRow(turn){
4133
+ var row = document.createElement('div');
4134
+ row.className = 'rk-ip-media-actions';
4135
+ var full = document.createElement('button');
4136
+ full.type = 'button'; full.className = 'rk-ip-act'; full.dataset.ipOpen = turn.id;
4137
+ full.setAttribute('aria-label', 'Open fullscreen'); full.title = 'Fullscreen';
4138
+ full.innerHTML = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3h5v5\"/><path d=\"M8 17H3v-5\"/><path d=\"M17 3l-6 6\"/><path d=\"M3 17l6-6\"/></svg>";
4139
+ var download = document.createElement('a');
4140
+ download.className = 'rk-ip-act'; download.href = turn.url; download.target = '_blank'; download.rel = 'noopener noreferrer';
4141
+ download.setAttribute('download', imageDownloadName(turn.mediaKind === 'video' ? 'vidfarm-video' : 'vidfarm-image', turn.at));
4142
+ download.setAttribute('aria-label', 'Download this file'); download.title = 'Download';
4143
+ download.innerHTML = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 3v9\"/><path d=\"m6.5 8.5 3.5 3.5 3.5-3.5\"/><path d=\"M4 16h12\"/></svg>";
4144
+ var link = document.createElement('a');
4145
+ link.className = 'rk-ip-act'; link.href = turn.url; link.target = '_blank'; link.rel = 'noopener noreferrer';
4146
+ link.setAttribute('aria-label', 'Open the direct file URL'); link.title = 'Open direct file URL';
4147
+ link.innerHTML = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8.6 11.4a3 3 0 0 0 4.3 0l2.4-2.4a3 3 0 1 0-4.3-4.3l-1 1\"/><path d=\"M11.4 8.6a3 3 0 0 0-4.3 0l-2.4 2.4a3 3 0 1 0 4.3 4.3l1-1\"/></svg>";
4148
+ var copy = document.createElement('button');
4149
+ copy.type = 'button'; copy.className = 'rk-ip-act'; copy.dataset.ipCopy = turn.id;
4150
+ copy.setAttribute('aria-label', 'Copy the file URL'); copy.title = 'Copy URL';
4151
+ copy.innerHTML = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8.4 11.6a3.2 3.2 0 0 0 4.5 0l2.2-2.2a3.2 3.2 0 0 0-4.5-4.5L9.4 6.1\"/><path d=\"M11.6 8.4a3.2 3.2 0 0 0-4.5 0L4.9 10.6a3.2 3.2 0 0 0 4.5 4.5l1.2-1.2\"/></svg>";
4152
+ var save = document.createElement('button');
4153
+ save.type = 'button'; save.className = 'rk-ip-act'; save.dataset.ipSave = turn.id;
4154
+ save.setAttribute('aria-label', 'Save to My Files'); save.title = 'Save to My Files';
4155
+ save.innerHTML = "<svg width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 4h8l2 2v10H5z\"/><path d=\"M8 4v4h4V4\"/><path d=\"M8 12h4\"/></svg>";
4156
+ row.append(full, download, link, copy, save);
4157
+ return row;
4158
+ }
4159
+
4160
+ function renderChat(){
4161
+ // Detach the paint surface first — innerHTML rebuild would otherwise drop it.
4162
+ if(paintStack.parentElement !== canvasHolder) canvasHolder.appendChild(paintStack);
4163
+ var wasNearBottom = (chatFeed.scrollHeight - chatFeed.scrollTop - chatFeed.clientHeight) < 120;
4164
+ chatFeed.innerHTML = '';
4165
+ if(!chatTurns.length){
4166
+ emptyState.hidden = false;
4167
+ chatFeed.appendChild(emptyState);
4168
+ return;
4169
+ }
4170
+ emptyState.hidden = true;
4171
+ var latestPaintableId = '';
4172
+ for(var i = chatTurns.length - 1; i >= 0; i--){
4173
+ var candidate = chatTurns[i];
4174
+ if(candidate.kind === 'media' && candidate.mediaKind !== 'video' && candidate.url && candidate.url === sourceImageUrl){ latestPaintableId = candidate.id; break; }
4175
+ }
4176
+ chatTurns.forEach(function(turn){
4177
+ var row = document.createElement('div');
4178
+ row.className = 'rk-ip-turn';
4179
+ row.dataset.role = turn.role;
4180
+ row.dataset.kind = turn.kind;
4181
+ row.dataset.turnId = turn.id;
4182
+ if(turn.role === 'user'){
4183
+ if(turn.refs && turn.refs.length){
4184
+ var refs = document.createElement('div');
4185
+ refs.className = 'rk-ip-turn-refs';
4186
+ turn.refs.forEach(function(url){ var im = document.createElement('img'); im.src = url; im.alt = ''; im.loading = 'lazy'; refs.appendChild(im); });
4187
+ row.appendChild(refs);
4188
+ }
4189
+ var tag = document.createElement('span');
4190
+ tag.className = 'rk-ip-turn-tag';
4191
+ tag.textContent = turn.mode === 'video' ? 'Video' : (turn.masked ? 'Masked edit' : (turn.isEdit ? 'Edit' : 'New image'));
4192
+ row.appendChild(tag);
4193
+ var bubble = document.createElement('div');
4194
+ bubble.className = 'rk-ip-bubble';
4195
+ bubble.textContent = turn.text || '';
4196
+ row.appendChild(bubble);
4197
+ } else if(turn.kind === 'pending'){
4198
+ var pending = document.createElement('div');
4199
+ pending.className = 'rk-ip-pending';
4200
+ pending.dataset.pendingId = turn.id;
4201
+ pending.textContent = turn.label || 'Working\u2026';
4202
+ row.appendChild(pending);
4203
+ } else if(turn.kind === 'error' || turn.kind === 'stopped'){
4204
+ var err = document.createElement('div');
4205
+ err.className = 'rk-ip-bubble';
4206
+ err.textContent = turn.text || 'Something went wrong.';
4207
+ row.appendChild(err);
4208
+ } else if(turn.kind === 'media' && turn.url){
4209
+ var media = document.createElement('div');
4210
+ media.className = 'rk-ip-media';
4211
+ var isLatest = turn.id === latestPaintableId;
4212
+ var frame = document.createElement('div');
4213
+ frame.className = 'rk-ip-media-frame' + (isLatest ? ' is-latest' : '');
4214
+ if(isLatest){
4215
+ canvasWrap.hidden = false;
4216
+ frame.appendChild(paintStack);
4217
+ } else if(turn.mediaKind === 'video'){
4218
+ var video = document.createElement('video');
4219
+ video.src = turn.url; video.controls = true; video.playsInline = true; video.preload = 'metadata';
4220
+ frame.appendChild(video);
4221
+ } else {
4222
+ var img = document.createElement('img');
4223
+ img.src = turn.url; img.alt = turn.note || 'Generated image'; img.loading = 'lazy';
4224
+ frame.appendChild(img);
4225
+ }
4226
+ media.appendChild(frame);
4227
+ var actions = mediaActionsRow(turn);
4228
+ if(isLatest){
4229
+ var note = document.createElement('span');
4230
+ note.className = 'rk-ip-act-note';
4231
+ note.textContent = inpaintStrokes.length ? 'Newest \u00b7 masked' : 'Newest \u00b7 paint to mask a region';
4232
+ actions.appendChild(note);
4233
+ } else if(turn.note){
4234
+ var tagNote = document.createElement('span');
4235
+ tagNote.className = 'rk-ip-act-note';
4236
+ tagNote.textContent = turn.note;
4237
+ actions.appendChild(tagNote);
4238
+ }
4239
+ media.appendChild(actions);
4240
+ row.appendChild(media);
4241
+ } else {
4242
+ var text = document.createElement('div');
4243
+ text.className = 'rk-ip-bubble';
4244
+ text.textContent = turn.text || '';
4245
+ row.appendChild(text);
4246
+ }
4247
+ chatFeed.appendChild(row);
4248
+ });
4249
+ if(wasNearBottom) chatFeed.scrollTop = chatFeed.scrollHeight;
4250
+ updateBrushCursorSize();
4251
+ syncSessionAssets();
4252
+ }
4253
+
4254
+ function turnById(id){ return chatTurns.filter(function(t){ return t.id === id; })[0] || null; }
4255
+
4256
+ chatFeed.addEventListener('click', function(event){
4257
+ var target = event.target;
4258
+ if(!(target instanceof Element)) return;
4259
+ var open = target.closest('[data-ip-open]');
4260
+ if(open instanceof HTMLElement){
4261
+ var openTurn = turnById(open.dataset.ipOpen || '');
4262
+ if(openTurn) openImagePreview(openTurn.url, openTurn.mediaKind === 'video' ? 'Generated video' : 'Generated image', imageDownloadName(openTurn.mediaKind === 'video' ? 'vidfarm-video' : 'vidfarm-image', openTurn.at), openTurn.mediaKind);
4263
+ return;
4264
+ }
4265
+ var copy = target.closest('[data-ip-copy]');
4266
+ if(copy instanceof HTMLElement){
4267
+ var copyTurn = turnById(copy.dataset.ipCopy || '');
4268
+ if(!copyTurn) return;
4269
+ void copyText(copyTurn.url).then(function(copied){
4270
+ copy.title = copied ? 'Copied' : 'Copy failed';
4271
+ setStatus(copied ? 'File URL copied.' : 'Copy failed.', copied ? 'success' : 'error');
4272
+ window.setTimeout(function(){ copy.title = 'Copy URL'; }, 1200);
4273
+ });
4274
+ return;
4275
+ }
4276
+ var save = target.closest('[data-ip-save]');
4277
+ if(save instanceof HTMLElement){
4278
+ var saveTurn = turnById(save.dataset.ipSave || '');
4279
+ if(saveTurn) saveMediaToFiles(saveTurn.url, imageDownloadName(saveTurn.mediaKind === 'video' ? 'vidfarm-video' : 'vidfarm-image', saveTurn.at));
4280
+ }
4281
+ });
4282
+
4283
+ maskChip.addEventListener('click', function(event){
4284
+ var target = event.target;
4285
+ if(target instanceof Element && target.closest('[data-ip-clear-mask]')){
4286
+ resetMask();
4287
+ renderChat();
4288
+ composerPlaceholder();
4289
+ }
4290
+ });
4291
+
4292
+ // ---- what each request carries -------------------------------------------
4293
+ // DELIBERATELY no conversation context. Each turn sends ONLY the user's latest
4294
+ // message and (for edits) the current image. Feeding back a transcript plus
4295
+ // earlier versions of the same picture made the models blend instructions
4296
+ // across turns and drift back toward superseded versions. The newest image
4297
+ // already encodes every edit before it, so it is the only history worth
4298
+ // sending. References the user attaches by hand still go through untouched.
4299
+ // ---- job polling ---------------------------------------------------------
4300
+ function extractVideoOutputUrl(payload){
4301
+ var result = (payload && payload.result) || {};
4302
+ var out = result.output || {};
4303
+ return (out.video && out.video.file_url) || out.primary_file_url || (result.video && result.video.file_url) || result.primary_file_url
4304
+ || (Array.isArray(out.files) ? out.files[0] : '') || (Array.isArray(result.files) ? result.files[0] : '') || '';
4305
+ }
4306
+ function pollPrimitiveJob(kind, jobId, label, isVideo){
4307
+ var attempt = 0;
4308
+ var maxAttempts = isVideo ? 150 : 90;
4309
+ function step(){
4310
+ throwIfStopped();
4311
+ return fetch('/api/v1/primitives/' + kind + '/jobs/' + encodeURIComponent(jobId), { credentials: 'same-origin' }).then(function(r){
4312
+ return parseJsonResponse(r).then(function(payload){
4313
+ if(!r.ok) throw new Error(payload.error || ('Unable to poll ' + label + '.'));
4314
+ if(payload.status === 'failed' || payload.status === 'cancelled'){ throw new Error((payload.error && payload.error.message) || (label + ' ' + payload.status + '.')); }
4315
+ var out = isVideo ? extractVideoOutputUrl(payload) : extractOutputUrl(payload);
4316
+ if(payload.status === 'succeeded' || payload.status === 'complete' || out){
4317
+ if(!out) throw new Error(label + ' finished without an output URL.');
4318
+ return absoluteUrl(out);
4319
+ }
4320
+ var note = label + ' (' + (payload.status || 'running') + ')\u2026';
4321
+ updatePendingLabel(activeTurnId, note);
4322
+ setStatus(note, '');
4323
+ attempt++;
4324
+ if(attempt >= maxAttempts){ return ''; }
4325
+ return new Promise(function(res){ setTimeout(res, attempt < 8 ? 1500 : (isVideo ? 4000 : 3000)); }).then(step);
4326
+ });
4327
+ });
4328
+ }
4329
+ return step();
3793
4330
  }
3794
4331
 
3795
- if(genPrompt){
3796
- genPrompt.addEventListener('input', syncGenSend);
3797
- genPrompt.addEventListener('keydown', function(e){ if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); submitGenerate(); } });
3798
- genPrompt.addEventListener('paste', function(e){ var imgs = imagesFromClipboard(e); if(imgs.length){ e.preventDefault(); addGenReferenceFiles(imgs); } });
4332
+ // ---- the four kinds of turn ---------------------------------------------
4333
+ function runGenerateTurn(text, refs){
4334
+ return Promise.resolve().then(function(){
4335
+ var pending = addTurn({ kind: 'pending', label: 'Generating image\u2026', prompt: text });
4336
+ activeTurnId = pending.id;
4337
+ var payload = { prompt: text, output_format: 'png' };
4338
+ if(refs.length) payload.prompt_attachments = refs;
4339
+ if(inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
4340
+ var tracer = tracerAutoPending ? nameTracerFromSeed(text) : getTracerValue();
4341
+ return fetch('/api/v1/primitives/images/generate', {
4342
+ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
4343
+ body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: text, chat_mode: 'image' })
4344
+ }).then(parseJsonResponse).then(function(j){
4345
+ if(!j || !j.job_id) throw new Error((j && j.error) || 'Unable to start image generation.');
4346
+ activeJob = { kind: 'image_generate', jobId: j.job_id };
4347
+ return pollPrimitiveJob('image_generate', j.job_id, 'Generating image', false).then(function(out){
4348
+ if(!out){ updatePendingLabel(activeTurnId, 'Still generating \u2014 it will appear in this session\u2019s history when it lands.'); activeTurnId = ''; return; }
4349
+ resolveActiveTurn({ kind: 'media', mediaKind: 'image', url: out, jobId: j.job_id });
4350
+ adoptLatestImage(out);
4351
+ recordSourceHistoryItem(out, 'generated', 'Generated image');
4352
+ setStatus('Image ready \u2014 keep chatting to refine it.', 'success');
4353
+ void loadTracerSessions();
4354
+ });
4355
+ });
4356
+ });
3799
4357
  }
3800
- if(genWrap){
3801
- ['dragenter', 'dragover'].forEach(function(ev){ genWrap.addEventListener(ev, function(e){ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); genWrap.classList.add('is-dragref'); } }); });
3802
- ['dragleave', 'dragend'].forEach(function(ev){ genWrap.addEventListener(ev, function(){ genWrap.classList.remove('is-dragref'); }); });
3803
- genWrap.addEventListener('drop', function(e){ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); genWrap.classList.remove('is-dragref'); addGenReferenceFiles(e.dataTransfer.files); } });
4358
+
4359
+ function runEditTurn(text, refs){
4360
+ return Promise.resolve().then(function(){
4361
+ var pending = addTurn({ kind: 'pending', label: 'Editing the newest image\u2026', prompt: text });
4362
+ activeTurnId = pending.id;
4363
+ var instruction = 'Edit the SOURCE IMAGE as follows: ' + text
4364
+ + ' Keep everything else about the source image \u2014 subject identity, composition, framing, style, and unrelated details \u2014 unchanged unless the instruction asks otherwise.';
4365
+ var payload = { source_image_url: sourceImageUrl, instruction: instruction, output_format: 'png' };
4366
+ if(refs.length) payload.reference_attachments = refs;
4367
+ if(inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
4368
+ var tracer = tracerAutoPending ? nameTracerFromSeed(text) : getTracerValue();
4369
+ return fetch('/api/v1/primitives/images/edit', {
4370
+ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
4371
+ body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: text, chat_mode: 'image' })
4372
+ }).then(parseJsonResponse).then(function(j){
4373
+ if(!j || !j.job_id) throw new Error((j && j.error) || 'Unable to start the edit.');
4374
+ activeJob = { kind: 'image_edit', jobId: j.job_id };
4375
+ return pollPrimitiveJob('image_edit', j.job_id, 'Editing image', false).then(function(out){
4376
+ if(!out){ updatePendingLabel(activeTurnId, 'Still editing \u2014 it will appear in this session\u2019s history when it lands.'); activeTurnId = ''; return; }
4377
+ resolveActiveTurn({ kind: 'media', mediaKind: 'image', url: out, jobId: j.job_id });
4378
+ adoptLatestImage(out);
4379
+ void persistInpaintOutputToTemp(out, j.job_id);
4380
+ setStatus('Edit applied \u2014 the newest image is now the one you are editing.', 'success');
4381
+ void loadTracerSessions();
4382
+ });
4383
+ });
4384
+ });
4385
+ }
4386
+
4387
+ function runMaskedTurn(text, refs){
4388
+ return Promise.resolve().then(function(){
4389
+ // The composer text is the legacy "whole image prompt"; per-color prompts
4390
+ // stay in the left panel and are merged by buildInpaintInstruction().
4391
+ inpaintGlobalPrompt.value = text;
4392
+ var pending = addTurn({ kind: 'pending', label: 'Applying the masked edit\u2026', prompt: text });
4393
+ activeTurnId = pending.id;
4394
+ return submitInpaintJob({ extraRefs: refs, chatMessage: text });
4395
+ });
3804
4396
  }
3805
- if(genClip) genClip.addEventListener('click', openGenFilePanel);
3806
- if(genSend) genSend.addEventListener('click', submitGenerate);
4397
+
4398
+ // ---- send ----------------------------------------------------------------
4399
+ function sendChatMessage(){
4400
+ if(isGenBusy()) return;
4401
+ var text = expandShorthand((genPrompt && genPrompt.value || '').trim());
4402
+ var masked = inpaintStrokes.length > 0;
4403
+ if(!text && !(masked && maskHasInstruction())){
4404
+ setStatus('Type what you want to create or change.', 'error');
4405
+ if(genPrompt) genPrompt.focus();
4406
+ return;
4407
+ }
4408
+ var refs = composer.refs();
4409
+ addTurn({
4410
+ role: 'user', kind: 'text',
4411
+ text: text || 'Apply the painted mask instructions.',
4412
+ refs: refs, mode: 'image', masked: masked,
4413
+ isEdit: Boolean(sourceImageUrl)
4414
+ });
4415
+ if(genPrompt){ genPrompt.value = ''; composer.resetHeight(); }
4416
+ composer.clearRefs();
4417
+ stopRequested = false;
4418
+ activeJob = { kind: '', jobId: '' };
4419
+ setGenBusy(true);
4420
+ setStatus('Working\u2026', '');
4421
+ var work;
4422
+ if(!sourceImageUrl) work = runGenerateTurn(text, refs);
4423
+ else if(masked) work = runMaskedTurn(text, refs);
4424
+ else work = runEditTurn(text, refs);
4425
+ work.catch(function(err){
4426
+ if(err && (err.stopped || err.message === '__vidfarm_stopped__')){
4427
+ resolveActiveTurn({ kind: 'stopped', text: 'Stopped.' });
4428
+ setStatus('Stopped.', '');
4429
+ return;
4430
+ }
4431
+ var msg = (err && err.message) ? err.message : 'That request failed.';
4432
+ if(/401|403|sign in|unauth/i.test(msg)) msg = 'Sign in to generate media.';
4433
+ resolveActiveTurn({ kind: 'error', text: msg });
4434
+ setStatus(msg, 'error');
4435
+ }).then(function(){
4436
+ stopRequested = false;
4437
+ activeJob = { kind: '', jobId: '' };
4438
+ setGenBusy(false);
4439
+ composerPlaceholder();
4440
+ syncGenSend();
4441
+ void loadInpaintHistory();
4442
+ });
4443
+ }
4444
+
4445
+ // input / Enter-to-send / paste / drag-drop / paperclip / send-stop are all
4446
+ // wired by the shared composer runtime above.
4447
+ // ⌘Z / ⇧⌘Z anywhere outside a text field undoes and redoes the paint.
4448
+ window.addEventListener('keydown', function(e){
4449
+ if(!(e.metaKey || e.ctrlKey) || String(e.key).toLowerCase() !== 'z') return;
4450
+ var el = document.activeElement;
4451
+ if(el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) return;
4452
+ if(!inpaintStrokes.length && !redoStrokes.length) return;
4453
+ e.preventDefault();
4454
+ if(e.shiftKey) redoStroke(); else undoStroke();
4455
+ });
3807
4456
  renderGenRefs();
3808
4457
  syncGenSend();
4458
+ composerPlaceholder();
4459
+ renderChat();
3809
4460
 
3810
4461
  // Drag-and-drop onto the upload dropzone (legacy only wired click).
3811
4462
  var dropZone = document.querySelector('[data-custom-inpaint-upload]');
@@ -4365,6 +5016,34 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4365
5016
  }
4366
5017
  function fileIcon(kind) { return kind === 'pdf' ? '📕' : kind === 'json' ? '{ }' : kind === 'markdown' ? '📝' : kind === 'audio' ? '🎵' : kind === 'text' ? '📄' : '📄'; }
4367
5018
 
5019
+ // ── "edit this media" hand-off: dock chat → the matching creative tool ──
5020
+ // Images open /tools/image, videos /tools/video, each with the file preloaded as
5021
+ // that tool's working file (its chat then edits THAT media). Deliberately a fresh
5022
+ // tool session — no ?tracer — so the tool names its own chat from the first prompt.
5023
+ var PENCIL_SVG = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.6 3.6a1.9 1.9 0 0 1 2.8 2.8L7.7 15.1l-3.6.8.8-3.6z"/></svg>';
5024
+ function toolEditUrl(file) {
5025
+ if (!file || !file.viewUrl) return '';
5026
+ var k = fileKind(file.name, file.contentType);
5027
+ var name = file.name || '';
5028
+ if (k === 'image') return '/tools/image?source_image_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_image_name=' + encodeURIComponent(name) : '');
5029
+ if (k === 'video') return '/tools/video?source_video_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_video_name=' + encodeURIComponent(name) : '');
5030
+ return '';
5031
+ }
5032
+ function toolEditLink(file, className) {
5033
+ var href = toolEditUrl(file);
5034
+ if (!href) return null;
5035
+ var a = document.createElement('a');
5036
+ a.className = className;
5037
+ a.href = href;
5038
+ a.target = '_blank';
5039
+ a.rel = 'noopener noreferrer';
5040
+ a.title = 'Edit in the ' + (fileKind(file.name, file.contentType) === 'video' ? 'video' : 'image') + ' tool';
5041
+ a.setAttribute('aria-label', a.title);
5042
+ // The card itself opens the lightbox; the pencil must not also trigger it.
5043
+ a.addEventListener('click', function (e) { e.stopPropagation(); });
5044
+ return a;
5045
+ }
5046
+
4368
5047
  // Compact markdown → HTML for .md previews (headings, lists, quotes, rules,
4369
5048
  // fenced + inline code, bold/italic/links). Escapes first, then injects tags
4370
5049
  // (same order as linkify). Backticks are built via char codes to avoid
@@ -4425,7 +5104,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4425
5104
  var dlBtn = document.createElement('a'); dlBtn.className = 'rk-aichat-preview-act'; dlBtn.target = '_blank'; dlBtn.rel = 'noopener noreferrer'; dlBtn.textContent = 'Download';
4426
5105
  var linkBtn = document.createElement('button'); linkBtn.type = 'button'; linkBtn.className = 'rk-aichat-preview-act'; linkBtn.textContent = 'Copy link';
4427
5106
  var xBtn = document.createElement('button'); xBtn.type = 'button'; xBtn.className = 'rk-aichat-modal-x'; xBtn.setAttribute('aria-label', 'Close'); xBtn.innerHTML = '&times;';
4428
- head.appendChild(nameEl); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
5107
+ // Same hand-off as the card pencil, spelled out as a labelled action here.
5108
+ var editBtn = document.createElement('a'); editBtn.className = 'rk-aichat-preview-act'; editBtn.target = '_blank'; editBtn.rel = 'noopener noreferrer'; editBtn.textContent = 'Edit';
5109
+ head.appendChild(nameEl); head.appendChild(editBtn); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
4429
5110
  function flashAct(btn, label) { var o = btn.getAttribute('data-label') || btn.textContent; btn.setAttribute('data-label', o); btn.textContent = label; setTimeout(function () { btn.textContent = o; }, 1400); }
4430
5111
  // Copy = file text (json/md/txt) or its URL for binaries. Copy link = the
4431
5112
  // durable public S3 URL. Download opens in a new tab when the browser can't
@@ -4474,6 +5155,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4474
5155
  var it = items[idx];
4475
5156
  nameEl.textContent = it.name || 'file'; nameEl.title = it.name || '';
4476
5157
  dlBtn.href = it.viewUrl; dlBtn.setAttribute('download', it.name || '');
5158
+ var editHref = toolEditUrl(it);
5159
+ editBtn.hidden = !editHref;
5160
+ if (editHref) { editBtn.href = editHref; editBtn.title = 'Edit in the ' + (fileKind(it.name, it.contentType) === 'video' ? 'video' : 'image') + ' tool'; }
4477
5161
  linkBtn.style.display = it.publicUrl ? '' : 'none';
4478
5162
  body.innerHTML = '';
4479
5163
  var kind = fileKind(it.name, it.contentType);
@@ -4528,7 +5212,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4528
5212
  var files = [], seen = {};
4529
5213
  var arts = (job && job.artifacts) || [];
4530
5214
  for (var i = 0; i < arts.length; i++) { var f = rkFileFromArtifact(arts[i]); if (f && !seen[f.viewUrl]) { seen[f.viewUrl] = true; files.push(f); } }
4531
- var mu = rkResultMediaUrl(job); if (mu && !seen[mu]) { seen[mu] = true; files.push({ name: (job.tracer || 'output'), contentType: '', viewUrl: mu, publicUrl: mu }); }
5215
+ // Name the direct result URL after the FILE, not the tracer fileKind() reads the
5216
+ // extension, and a tracer-named entry ("my-poster") classifies as 'other', losing
5217
+ // the image/video thumbnail and the edit-in-tool hand-off.
5218
+ var mu = rkResultMediaUrl(job);
5219
+ if (mu && !seen[mu]) {
5220
+ seen[mu] = true;
5221
+ var seg = String(mu).split('?')[0].split('/').pop() || '';
5222
+ files.push({ name: (/.[a-z0-9]{2,5}$/i.test(seg) ? seg : (job.tracer || 'output')), contentType: '', viewUrl: mu, publicUrl: mu });
5223
+ }
4532
5224
  var res = job && job.result;
4533
5225
  if (res && typeof res === 'object' && !Array.isArray(res) && Object.keys(res).length) {
4534
5226
  try { var blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' }); files.push({ name: (job.tracer || 'result') + '.json', contentType: 'application/json', viewUrl: URL.createObjectURL(blob), publicUrl: null }); } catch (e) {}
@@ -4581,14 +5273,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4581
5273
  var grid = document.createElement('div'); grid.className = 'rk-aichat-files-grid';
4582
5274
  list.forEach(function (f, ix) {
4583
5275
  var k = fileKind(f.name, f.contentType);
4584
- var card = document.createElement('button'); card.type = 'button'; card.className = 'rk-aichat-file';
5276
+ // A div, not a button: media cards carry a nested "edit in tool" link, and
5277
+ // an anchor inside a button is invalid (and unclickable in some browsers).
5278
+ var card = document.createElement('div'); card.className = 'rk-aichat-file'; card.tabIndex = 0; card.setAttribute('role', 'button');
4585
5279
  var thumb = document.createElement('div'); thumb.className = 'rk-aichat-file-thumb rk-aichat-file-thumb--' + k;
4586
5280
  if (k === 'image') { var im = document.createElement('img'); im.src = f.viewUrl; im.alt = ''; im.loading = 'lazy'; thumb.appendChild(im); }
4587
5281
  else if (k === 'video') { var v = document.createElement('video'); v.src = f.viewUrl; v.muted = true; v.preload = 'metadata'; v.playsInline = true; thumb.appendChild(v); var pl = document.createElement('span'); pl.className = 'rk-aichat-file-play'; pl.textContent = '▶'; thumb.appendChild(pl); }
4588
5282
  else { var ic = document.createElement('span'); ic.className = 'rk-aichat-file-ic'; ic.textContent = fileIcon(k); thumb.appendChild(ic); }
5283
+ var edit = toolEditLink(f, 'rk-aichat-file-open');
5284
+ if (edit) { edit.innerHTML = PENCIL_SVG; thumb.appendChild(edit); }
4589
5285
  var nm = document.createElement('div'); nm.className = 'rk-aichat-file-name'; nm.textContent = f.name || 'file'; nm.title = f.name || '';
4590
5286
  card.appendChild(thumb); card.appendChild(nm);
4591
5287
  card.addEventListener('click', function () { openFilePreview(list, ix); });
5288
+ card.addEventListener('keydown', function (e) {
5289
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFilePreview(list, ix); }
5290
+ });
4592
5291
  grid.appendChild(card);
4593
5292
  });
4594
5293
  wrap.appendChild(grid); turn.appendChild(wrap); logBottom();