@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}
@@ -1088,8 +1097,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1088
1097
  .rk-inpaint-page{padding-left:var(--rk-gutter);padding-right:var(--rk-gutter)}
1089
1098
  .rk-inpaint-workbench{display:grid;grid-template-columns:320px minmax(0,1fr) 320px;gap:20px;align-items:start}
1090
1099
  @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}}
1092
- .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)}
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}}
1101
+ .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)}
1093
1102
  .rk-inpaint-panel{padding:18px;display:grid;gap:16px;align-content:start}
1094
1103
  .rk-inpaint-block{display:grid;gap:9px}
1095
1104
  .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}
@@ -1164,8 +1173,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1164
1173
  .rk-inpaint-status[data-tone="success"]{color:#1f7a44}
1165
1174
 
1166
1175
  /* center video stage */
1167
- .rk-inpaint-stage{min-height:420px;display:grid;place-items:center;padding:20px;overflow:hidden}
1168
- .rk-ipv-stage{align-content:center}
1169
1176
  .rk-inpaint-empty{display:grid;justify-items:center;gap:8px;text-align:center;color:var(--rk-text-muted);padding:30px 20px}
1170
1177
  .rk-inpaint-empty[hidden]{display:none}
1171
1178
  .rk-inpaint-empty-ic{color:var(--rk-n-400)}
@@ -1192,44 +1199,110 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1192
1199
  .rk-ipv-timecode-copy{width:30px;height:30px}
1193
1200
  .rk-ipv-duration{font-size:11.5px;font-weight:700;color:var(--rk-text-faint);font-variant-numeric:tabular-nums;white-space:nowrap}
1194
1201
 
1195
- /* text-to-video composer (empty state) */
1196
- .rk-ip-gen{width:100%;max-width:460px;margin-top:16px;display:grid;gap:10px;justify-items:stretch}
1197
- .rk-ip-gen-or{position:relative;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--rk-text-faint)}
1198
- .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)}
1199
- .rk-ip-gen-or::before{left:0}.rk-ip-gen-or::after{right:0}
1200
- .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;box-shadow:var(--rk-shadow-sm);text-align:left}
1201
- .rk-ip-gen-box:focus-within{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1202
- .rk-ip-gen.is-dragref .rk-ip-gen-box{border-color:var(--rk-gold-600);border-style:dashed;background:var(--rk-gold-tint)}
1203
- .rk-ip-gen.is-busy .rk-ip-gen-box{opacity:.75}
1204
- .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}
1205
- .rk-ip-gen-input::placeholder{color:var(--rk-text-faint)}
1202
+
1203
+ /* ---- shared chat surface (tools/image + tools/video) ---------------------- */
1204
+ .rk-ip-chat{display:grid;grid-template-rows:minmax(0,1fr) auto;height:min(76vh,860px);min-height:520px;overflow:hidden}
1205
+ .rk-ip-feed{min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:18px 18px 6px;display:flex;flex-direction:column;gap:14px}
1206
+ .rk-ip-canvasholder{display:none}
1207
+ .rk-ip-turn{display:flex;flex-direction:column;gap:6px;max-width:100%}
1208
+ .rk-ip-turn[data-role="user"]{align-items:flex-end}
1209
+ .rk-ip-turn[data-role="assistant"]{align-items:flex-start}
1210
+ .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}
1211
+ .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)}
1212
+ .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)}
1213
+ .rk-ip-turn-tag{font-size:10.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--rk-text-faint)}
1214
+ .rk-ip-turn-refs{display:flex;flex-wrap:wrap;gap:5px;justify-content:flex-end}
1215
+ .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}
1216
+ .rk-ip-media{width:fit-content;max-width:min(100%,640px);display:grid;gap:7px}
1217
+ .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}
1218
+ .rk-ip-media-frame.is-latest{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1219
+ .rk-ip-media-frame img,.rk-ip-media-frame video{display:block;max-width:100%;max-height:min(52vh,520px);width:auto;height:auto}
1220
+ .rk-ip-media-actions{display:flex;flex-wrap:wrap;align-items:center;gap:5px}
1221
+ .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)}
1222
+ .rk-ip-act:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1223
+ .rk-ip-act-note{font-size:11.5px;font-weight:700;color:var(--rk-text-faint);margin-left:2px}
1224
+ .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)}
1225
+ .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}
1226
+ .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)}
1227
+ .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}
1228
+ .rk-ip-turn[data-kind="error"] .rk-ip-bubble{background:#fdf3f2;border-color:#e6a8a0;color:#b42318;font-weight:600}
1229
+
1230
+ /* ---- composer ------------------------------------------------------------ */
1231
+ .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;
1232
+ border-bottom-left-radius:var(--rk-r-3xl);border-bottom-right-radius:var(--rk-r-3xl)}
1233
+ .rk-ip-composer.is-dragref{background:var(--rk-gold-tint)}
1234
+ .rk-ip-composer.is-busy .rk-ip-comp-status{color:var(--rk-gold-700)}
1235
+ /* The grip is anchored to this wrapper, not the textarea, so it never scrolls
1236
+ away with the textarea's own overflow once the message passes 10 rows. */
1237
+ .rk-ip-comp-field{position:relative;display:block}
1238
+ .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;
1239
+ 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)}
1240
+ .rk-ip-comp-input:focus{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1241
+ .rk-ip-comp-input::placeholder{color:var(--rk-text-faint)}
1242
+ .rk-ip-comp-input:disabled{cursor:not-allowed;color:var(--rk-text-muted);background:var(--rk-n-50)}
1243
+ .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;
1244
+ border-radius:4px;opacity:.75;transition:color var(--rk-dur) var(--rk-ease),opacity var(--rk-dur) var(--rk-ease)}
1245
+ .rk-ip-comp-grip:hover,.rk-ip-comp-field.is-resizing .rk-ip-comp-grip{color:var(--rk-gold-700);opacity:1}
1246
+ .rk-ip-comp-field.is-resizing{user-select:none}
1247
+ .rk-ip-comp-bar{display:flex;align-items:center;gap:8px}
1248
+ .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);
1249
+ font:inherit;font-size:12px;font-weight:800;padding:7px 26px 7px 12px;cursor:pointer;outline:0;
1250
+ 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");
1251
+ background-repeat:no-repeat;background-position:right 9px center}
1252
+ .rk-ip-comp-modeselect:focus{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1253
+ .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}
1254
+ .rk-ip-comp-status[data-tone="error"]{color:#b42318}
1255
+ .rk-ip-comp-status[data-tone="success"]{color:#1f7a44}
1256
+ .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);
1257
+ font-size:11.5px;font-weight:700;color:var(--rk-gold-700);justify-self:start;max-width:100%}
1258
+ .rk-ip-comp-mask[hidden]{display:none}
1259
+ .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}
1260
+
1261
+ /* ---- attachment chips ---------------------------------------------------- */
1206
1262
  .rk-ip-gen-refs{display:flex;flex-wrap:wrap;gap:6px}
1207
1263
  .rk-ip-gen-refs[hidden]{display:none}
1208
1264
  .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}
1209
1265
  .rk-ip-gen-ref img{width:22px;height:22px;border-radius:4px;object-fit:cover;flex:none}
1210
1266
  .rk-ip-gen-ref span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1211
1267
  .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}
1212
- .rk-ip-gen-bar{display:flex;align-items:center;gap:8px}
1213
- .rk-ip-gen-hint{flex:1;font-size:11.5px;color:var(--rk-text-faint);line-height:1.35}
1214
- .rk-ip-gen-clip,.rk-ip-gen-send{flex:none;display:grid;place-items:center;width:34px;height:34px;border-radius:50%;cursor:pointer}
1268
+ .rk-ip-gen-ref button:hover{color:var(--rk-ink)}
1269
+ .rk-ip-gen-ref.is-pending{opacity:.6}
1270
+ .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)}
1215
1271
  .rk-ip-gen-clip{border:1px solid var(--rk-border);background:#fff;color:var(--rk-n-600)}
1216
1272
  .rk-ip-gen-clip:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1273
+ .rk-ip-gen-clip:disabled{opacity:.5;cursor:not-allowed}
1217
1274
  .rk-ip-gen-send{border:0;background:var(--rk-gold-500,var(--rk-gold-600));color:var(--rk-ink)}
1218
1275
  .rk-ip-gen-send:hover:not(:disabled){background:var(--rk-gold-600)}
1219
1276
  .rk-ip-gen-send:disabled{opacity:.5;cursor:default}
1220
- .rk-ip-gen-send.is-busy{background:var(--rk-gold-tint);pointer-events:none}
1221
- .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}
1277
+ .rk-ip-gen-send[data-mode="stop"]{background:var(--rk-ink);color:#fff}
1222
1278
  .rk-ip-gen-status{font-size:12px;font-weight:600;min-height:16px;color:var(--rk-text-muted);text-align:left;padding-left:2px}
1279
+ .rk-ip-gen-status[hidden]{display:none}
1223
1280
  .rk-ip-gen-status[data-tone="error"]{color:#b42318}
1224
1281
  .rk-ip-gen-status[data-tone="success"]{color:#1f7a44}
1282
+ .rk-ip-gen-presets{display:flex;flex-wrap:wrap;gap:6px}
1283
+ .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)}
1284
+ .rk-ip-preset:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1225
1285
 
1226
- /* file-directory pop panel (reference picker) */
1227
- .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}
1286
+ /* ---- attach pop panel (shared file explorer) ----------------------------- */
1287
+ .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)}
1228
1288
  .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}
1229
1289
  .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)}
1230
1290
  .rk-ip-filemodal-title{font-family:var(--rk-font-display);font-weight:800;font-size:var(--rk-text-md,15px);color:var(--rk-ink)}
1291
+ .rk-ip-filemodal-actions{display:flex;align-items:center;gap:10px}
1292
+ .rk-ip-filemodal-upload{border:1px solid var(--rk-border-strong,var(--rk-border));background:var(--rk-n-50);color:var(--rk-n-700);border-radius:999px;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1293
+ .rk-ip-filemodal-upload:hover:not(:disabled){background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1294
+ .rk-ip-filemodal-upload:disabled{opacity:.6;cursor:default}
1231
1295
  .rk-ip-filemodal-close{border:0;background:transparent;font-size:22px;line-height:1;color:var(--rk-text-muted);cursor:pointer;padding:0 4px}
1296
+ .rk-ip-filemodal-close:hover{color:var(--rk-ink)}
1232
1297
  .rk-ip-filemodal-mount{padding:12px 14px;overflow:auto;flex:1;min-height:0}
1298
+ @keyframes rk-ip-spin{to{transform:rotate(360deg)}}
1299
+ @keyframes rk-ip-fade{from{opacity:0}to{opacity:1}}
1300
+ @media(max-width:820px){.rk-ip-chat{height:auto;min-height:0}.rk-ip-feed{max-height:62vh}}
1301
+
1302
+ .rk-ipv-quick{display:flex;flex-wrap:wrap;gap:6px;padding:10px 18px 0;background:var(--rk-surface)}
1303
+ .rk-ipv-quick .rk-ip-preset{display:inline-flex;align-items:center;gap:5px}
1304
+ .rk-ipv-quick .rk-ip-preset svg{width:12px;height:12px}
1305
+ .rk-ipv-quick + .rk-ip-composer{padding-top:8px;border-top:0}
1233
1306
 
1234
1307
  /* right: result + history */
1235
1308
  .rk-inpaint-output{padding:18px;display:grid;gap:18px;align-content:start}
@@ -1378,6 +1451,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1378
1451
  </div>
1379
1452
  <div class="rk-inpaint-toolbar">
1380
1453
  <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm rk-inpaint-startnew" data-ipv-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>
1454
+ <button type="button" class="rk-ip-iconbtn" data-ipv-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>
1381
1455
  <button type="button" class="rk-ip-iconbtn" data-ipv-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>
1382
1456
  <button type="button" class="rk-ip-iconbtn" data-ipv-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>
1383
1457
  </div>
@@ -1458,44 +1532,61 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1458
1532
  </section>
1459
1533
 
1460
1534
  <!-- CENTER: scrubbable video stage -->
1461
- <section class="rk-inpaint-stage rk-ipv-stage" aria-label="Video canvas">
1462
- <div class="rk-inpaint-empty" data-ipv-empty>
1463
- <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>
1464
- <p class="rk-inpaint-empty-title">Load a video to edit</p>
1465
- <p class="rk-inpaint-empty-sub">Drop a video here, or set a start &amp; end frame and a prompt to generate one.</p>
1466
- <button type="button" class="rk-btn rk-btn-ink rk-btn-sm rk-ipv-upload" data-ipv-upload><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> Upload video</button>
1467
- <input data-ipv-file type="file" accept="video/mp4,video/webm,video/quicktime,video/*" hidden />
1535
+ <!-- CENTER: chat feed + composer, mirroring /tools/image. The scrubbable
1536
+ player + mask canvas is ONE node that gets re-parented into the newest
1537
+ video bubble (exactly how the image page moves its paint stack), so a
1538
+ full feed re-render never tears down the <video> mid-playback. -->
1539
+ <section class="rk-ip-chat" aria-label="Create Video chat">
1540
+ <div class="rk-ip-feed" data-ipv-feed>
1541
+ <div class="rk-inpaint-empty" data-ipv-empty>
1542
+ <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>
1543
+ <p class="rk-inpaint-empty-title">Describe the video you want</p>
1544
+ <p class="rk-inpaint-empty-sub">Then keep chatting to refine it — every message regenerates from the newest clip. Upload a clip to edit instead, and paint timed masks on it to steer one region at one moment.</p>
1545
+ <button type="button" class="rk-btn rk-btn-ink rk-btn-sm rk-ipv-upload" data-ipv-upload><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> Upload video</button>
1546
+ </div>
1547
+ </div>
1468
1548
 
1469
- <div class="rk-ip-gen" id="rk-ipv-gen">
1470
- <span class="rk-ip-gen-or">or</span>
1471
- <div class="rk-ip-gen-box">
1472
- <textarea id="rk-ipv-genprompt" class="rk-ip-gen-input" rows="2" placeholder="Or describe the video you want"></textarea>
1473
- <div class="rk-ip-gen-refs" id="rk-ipv-genrefs" hidden></div>
1474
- <div class="rk-ip-gen-bar">
1475
- <button type="button" class="rk-ip-gen-clip" id="rk-ipv-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>
1476
- <span class="rk-ip-gen-hint">Generate a brand-new clip from a prompt (+ optional frames).</span>
1477
- <button type="button" class="rk-ip-gen-send" id="rk-ipv-gensend" aria-label="Generate video"><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>
1549
+ <!-- Parked here between renders; renderChat() moves it into the newest clip. -->
1550
+ <div class="rk-ip-canvasholder" data-ipv-canvas-holder>
1551
+ <div class="rk-ipv-player" data-ipv-player hidden>
1552
+ <div class="rk-ipv-canvaswrap" data-ipv-canvaswrap>
1553
+ <video data-ipv-video class="rk-ipv-video" playsinline></video>
1554
+ <canvas data-ipv-canvas class="rk-ipv-canvas"></canvas>
1555
+ <div class="editor-inpaint-brush-cursor" data-ipv-brush-cursor aria-hidden="true"></div>
1556
+ </div>
1557
+ <div class="rk-ipv-controls">
1558
+ <button type="button" class="rk-ipv-playpause" data-ipv-playpause aria-label="Play/pause"><svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 5.5v13a1 1 0 0 0 1.5.87l11-6.5a1 1 0 0 0 0-1.74l-11-6.5A1 1 0 0 0 7 5.5Z"/></svg></button>
1559
+ <input type="range" class="rk-ipv-scrub" data-ipv-scrub min="0" max="1000" step="1" value="0" aria-label="Seek" />
1560
+ <div class="rk-ipv-timecode">
1561
+ <input class="rk-ipv-timecode-input" data-ipv-timecode-input type="text" inputmode="numeric" spellcheck="false" value="00:00:000" aria-label="Current time MM:SS:mmm" />
1562
+ <button type="button" class="rk-ip-iconbtn rk-ipv-timecode-copy" data-ipv-timecode-copy aria-label="Copy current timecode" title="Copy timecode"><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>
1563
+ <span class="rk-ipv-duration" data-ipv-duration>/ 00:00:000</span>
1478
1564
  </div>
1479
1565
  </div>
1480
- <p class="rk-ip-gen-status" id="rk-ipv-genstatus" data-tone="" aria-live="polite"></p>
1481
1566
  </div>
1482
1567
  </div>
1483
1568
 
1484
- <div class="rk-ipv-player" data-ipv-player hidden>
1485
- <div class="rk-ipv-canvaswrap" data-ipv-canvaswrap>
1486
- <video data-ipv-video class="rk-ipv-video" playsinline></video>
1487
- <canvas data-ipv-canvas class="rk-ipv-canvas"></canvas>
1488
- <div class="editor-inpaint-brush-cursor" data-ipv-brush-cursor aria-hidden="true"></div>
1569
+ <div class="rk-ipv-quick" data-ipv-quick>
1570
+ <button type="button" class="rk-ip-preset" data-ipv-quick-video title="Load a clip to edit"><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> Upload Video</button>
1571
+ <button type="button" class="rk-ip-preset" data-ipv-quick-storyboard title="Attach a storyboard image as a visual reference"><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> Upload Storyboard</button>
1572
+ <input data-ipv-file type="file" accept="video/mp4,video/webm,video/quicktime,video/*" hidden />
1573
+ <input data-ipv-storyboard-input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple hidden />
1574
+ </div>
1575
+
1576
+ <div class="rk-ip-composer" data-ip-composer>
1577
+
1578
+ <div class="rk-ip-gen-refs" id="rk-ipv-genrefs" hidden></div>
1579
+ <div class="rk-ip-comp-field">
1580
+ <textarea id="rk-ipv-genprompt" class="rk-ip-comp-input" rows="2" placeholder="Describe the video you want…"></textarea>
1581
+ <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>
1489
1582
  </div>
1490
- <div class="rk-ipv-controls">
1491
- <button type="button" class="rk-ipv-playpause" data-ipv-playpause aria-label="Play/pause"><svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 5.5v13a1 1 0 0 0 1.5.87l11-6.5a1 1 0 0 0 0-1.74l-11-6.5A1 1 0 0 0 7 5.5Z"/></svg></button>
1492
- <input type="range" class="rk-ipv-scrub" data-ipv-scrub min="0" max="1000" step="1" value="0" aria-label="Seek" />
1493
- <div class="rk-ipv-timecode">
1494
- <input class="rk-ipv-timecode-input" data-ipv-timecode-input type="text" inputmode="numeric" spellcheck="false" value="00:00:000" aria-label="Current time MM:SS:mmm" />
1495
- <button type="button" class="rk-ip-iconbtn rk-ipv-timecode-copy" data-ipv-timecode-copy aria-label="Copy current timecode" title="Copy timecode"><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>
1496
- <span class="rk-ipv-duration" data-ipv-duration>/ 00:00:000</span>
1497
- </div>
1583
+ <div class="rk-ip-comp-bar">
1584
+
1585
+ <button type="button" class="rk-ip-gen-clip" id="rk-ipv-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>
1586
+ <p class="rk-ip-comp-status" data-ip-status data-ipv-comp-status data-tone="" aria-live="polite"></p>
1587
+ <button type="button" class="rk-ip-gen-send" id="rk-ipv-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>
1498
1588
  </div>
1589
+ <p class="rk-ip-gen-status" id="rk-ipv-genstatus" data-tone="" aria-live="polite" hidden></p>
1499
1590
  </div>
1500
1591
  </section>
1501
1592
 
@@ -1673,6 +1764,282 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1673
1764
  </aside>
1674
1765
  </div>
1675
1766
  <script>
1767
+
1768
+ (function(){
1769
+ var IMAGE_RE = /\.(png|jpe?g|gif|webp|avif)$/i;
1770
+ 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>";
1771
+ 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>";
1772
+ var MAX_ROWS = 10;
1773
+
1774
+ function isImageFile(f){ return /^image\//i.test((f && f.type) || '') || IMAGE_RE.test((f && f.name) || ''); }
1775
+ function imagesFromClipboard(e){
1776
+ var items = (e.clipboardData && e.clipboardData.items) || [], out = [];
1777
+ for(var i = 0; i < items.length; i++){
1778
+ if(items[i].kind === 'file'){ var f = items[i].getAsFile(); if(f && /^image\//i.test(f.type || '')) out.push(f); }
1779
+ }
1780
+ return out;
1781
+ }
1782
+ function dtHasFiles(e){
1783
+ var t = (e.dataTransfer && e.dataTransfer.types) || [];
1784
+ return Array.prototype.indexOf.call(t, 'Files') !== -1;
1785
+ }
1786
+ function plural(n, word){ return n + ' ' + word + (n > 1 ? 's' : ''); }
1787
+
1788
+ window.__rkChatComposer = function(cfg){
1789
+ cfg = cfg || {};
1790
+ var root = cfg.root, ta = cfg.textarea, refsEl = cfg.refsEl;
1791
+ var sendBtn = cfg.sendBtn, clipBtn = cfg.clipBtn;
1792
+ var uploadBlob = cfg.uploadBlob || function(){ return Promise.reject(new Error('Uploads are unavailable.')); };
1793
+ var status = cfg.onStatus || function(){};
1794
+ var canSendExtra = cfg.canSend || function(){ return false; };
1795
+ var refs = [];
1796
+ var busy = false;
1797
+ // Once the user drags the grip, their height wins: auto-grow stops fighting
1798
+ // them. Sending clears it so the next message starts compact again.
1799
+ var manualHeight = 0;
1800
+
1801
+ // ---- height ------------------------------------------------------------
1802
+ function maxHeight(){
1803
+ if(!ta) return 0;
1804
+ var s = window.getComputedStyle(ta);
1805
+ var line = parseFloat(s.lineHeight) || 20;
1806
+ var pad = (parseFloat(s.paddingTop) || 0) + (parseFloat(s.paddingBottom) || 0)
1807
+ + (parseFloat(s.borderTopWidth) || 0) + (parseFloat(s.borderBottomWidth) || 0);
1808
+ return Math.round(line * MAX_ROWS + pad);
1809
+ }
1810
+ function applyHeight(px, cap){
1811
+ ta.style.height = px + 'px';
1812
+ ta.style.overflowY = ta.scrollHeight > px + 1 ? 'auto' : (cap ? 'auto' : 'hidden');
1813
+ }
1814
+ function autoGrow(){
1815
+ if(!ta) return;
1816
+ if(manualHeight){ applyHeight(manualHeight, true); return; }
1817
+ var max = maxHeight();
1818
+ ta.style.height = 'auto';
1819
+ applyHeight(Math.min(max, Math.max(56, ta.scrollHeight)), ta.scrollHeight > max);
1820
+ }
1821
+ function resetHeight(){ manualHeight = 0; if(ta){ ta.style.height = 'auto'; } autoGrow(); }
1822
+
1823
+ // Corner grip: pointer-drag the textarea taller/shorter. Bounded so it can
1824
+ // never collapse under one row or grow past the viewport.
1825
+ var field = ta && ta.parentElement;
1826
+ var grip = root && root.querySelector('[data-ip-grip]');
1827
+ if(grip && ta){
1828
+ grip.addEventListener('pointerdown', function(e){
1829
+ e.preventDefault();
1830
+ var startY = e.clientY, startH = ta.getBoundingClientRect().height;
1831
+ var maxPx = Math.max(120, Math.round(window.innerHeight * 0.6));
1832
+ try{ grip.setPointerCapture(e.pointerId); }catch(_){}
1833
+ if(field) field.classList.add('is-resizing');
1834
+ function move(ev){
1835
+ var next = Math.min(maxPx, Math.max(56, Math.round(startH + (ev.clientY - startY))));
1836
+ manualHeight = next;
1837
+ applyHeight(next, true);
1838
+ }
1839
+ function up(ev){
1840
+ try{ grip.releasePointerCapture(e.pointerId); }catch(_){}
1841
+ if(field) field.classList.remove('is-resizing');
1842
+ grip.removeEventListener('pointermove', move);
1843
+ grip.removeEventListener('pointerup', up);
1844
+ grip.removeEventListener('pointercancel', up);
1845
+ // A click with no travel resets to auto-grow — an escape hatch out of
1846
+ // a height you dragged and no longer want.
1847
+ if(Math.abs(ev.clientY - startY) < 3) resetHeight();
1848
+ }
1849
+ grip.addEventListener('pointermove', move);
1850
+ grip.addEventListener('pointerup', up);
1851
+ grip.addEventListener('pointercancel', up);
1852
+ });
1853
+ grip.addEventListener('dblclick', function(){ resetHeight(); });
1854
+ }
1855
+
1856
+ // ---- send / stop button ------------------------------------------------
1857
+ function syncSend(){
1858
+ if(!sendBtn) return;
1859
+ if(busy){
1860
+ sendBtn.disabled = false;
1861
+ sendBtn.dataset.mode = 'stop';
1862
+ sendBtn.setAttribute('aria-label', 'Stop generating');
1863
+ sendBtn.setAttribute('title', 'Stop generating');
1864
+ sendBtn.innerHTML = STOP_ICON;
1865
+ return;
1866
+ }
1867
+ sendBtn.dataset.mode = 'send';
1868
+ sendBtn.setAttribute('aria-label', 'Send');
1869
+ sendBtn.setAttribute('title', 'Send');
1870
+ sendBtn.disabled = !(Boolean((ta && ta.value || '').trim()) || canSendExtra());
1871
+ sendBtn.innerHTML = SEND_ICON;
1872
+ }
1873
+ function setBusy(v){
1874
+ busy = Boolean(v);
1875
+ if(root) root.classList.toggle('is-busy', busy);
1876
+ syncSend();
1877
+ }
1878
+
1879
+ // ---- reference chips ---------------------------------------------------
1880
+ function renderRefs(){
1881
+ if(!refsEl) return;
1882
+ refsEl.hidden = refs.length === 0;
1883
+ refsEl.innerHTML = '';
1884
+ refs.forEach(function(url, idx){
1885
+ var chip = document.createElement('span'); chip.className = 'rk-ip-gen-ref';
1886
+ var im = document.createElement('img'); im.src = url; im.alt = ''; im.loading = 'lazy'; chip.appendChild(im);
1887
+ var lbl = document.createElement('span');
1888
+ var nm = url.split('/').pop() || 'reference';
1889
+ lbl.textContent = decodeURIComponent(nm).slice(0, 28);
1890
+ chip.appendChild(lbl);
1891
+ var x = document.createElement('button'); x.type = 'button';
1892
+ x.setAttribute('aria-label', 'Remove reference'); x.innerHTML = '&times;';
1893
+ x.addEventListener('click', function(){ refs.splice(idx, 1); renderRefs(); });
1894
+ chip.appendChild(x);
1895
+ refsEl.appendChild(chip);
1896
+ });
1897
+ }
1898
+
1899
+ // Paste or drop image files straight into the composer -> temp upload -> ref.
1900
+ function addFiles(fileList){
1901
+ var files = Array.prototype.slice.call(fileList || []).filter(isImageFile);
1902
+ if(!files.length) return Promise.resolve(0);
1903
+ status('Uploading ' + plural(files.length, 'reference') + '\u2026', '');
1904
+ var added = 0;
1905
+ return files.reduce(function(chain, f){
1906
+ return chain.then(function(){
1907
+ return uploadBlob(f, f.name || 'pasted-image.png', f.type || 'image/png').then(function(url){
1908
+ if(url && refs.indexOf(url) === -1){ refs.push(url); added++; renderRefs(); }
1909
+ });
1910
+ });
1911
+ }, Promise.resolve()).then(function(){
1912
+ if(added) status(plural(added, 'reference') + ' attached.', 'success');
1913
+ return added;
1914
+ }).catch(function(err){
1915
+ status((err && err.message) ? err.message : 'Upload failed.', 'error');
1916
+ return added;
1917
+ });
1918
+ }
1919
+
1920
+ // ---- attach pop panel (shared file-directory explorer) -----------------
1921
+ var modal = null;
1922
+ function onEsc(e){ if(e.key === 'Escape') closePanel(); }
1923
+ function closePanel(){
1924
+ if(!modal) return;
1925
+ try{ modal._handle && modal._handle.destroy && modal._handle.destroy(); }catch(_){}
1926
+ modal.remove(); modal = null;
1927
+ document.removeEventListener('keydown', onEsc, true);
1928
+ }
1929
+ function openPanel(){
1930
+ if(modal) return;
1931
+ var overlay = document.createElement('div'); overlay.className = 'rk-ip-filemodal';
1932
+ var panel = document.createElement('div'); panel.className = 'rk-ip-filemodal-panel';
1933
+ var head = document.createElement('div'); head.className = 'rk-ip-filemodal-head';
1934
+ var title = document.createElement('span'); title.className = 'rk-ip-filemodal-title';
1935
+ title.textContent = cfg.attachTitle || 'Attach reference images';
1936
+ var actions = document.createElement('div'); actions.className = 'rk-ip-filemodal-actions';
1937
+ var uploadBtn = document.createElement('button'); uploadBtn.type = 'button';
1938
+ uploadBtn.className = 'rk-ip-filemodal-upload'; uploadBtn.textContent = '\u2191 Upload from computer';
1939
+ var uploadInput = document.createElement('input'); uploadInput.type = 'file';
1940
+ uploadInput.accept = 'image/png,image/jpeg,image/webp,image/gif,image/avif';
1941
+ uploadInput.multiple = true; uploadInput.hidden = true;
1942
+ var close = document.createElement('button'); close.type = 'button';
1943
+ close.className = 'rk-ip-filemodal-close'; close.setAttribute('aria-label', 'Close'); close.innerHTML = '&times;';
1944
+ actions.appendChild(uploadBtn); actions.appendChild(close);
1945
+ head.appendChild(title); head.appendChild(actions); head.appendChild(uploadInput);
1946
+ uploadBtn.addEventListener('click', function(){ if(!uploadBtn.disabled) uploadInput.click(); });
1947
+ uploadInput.addEventListener('change', function(){
1948
+ var files = Array.prototype.slice.call(uploadInput.files || []).filter(isImageFile);
1949
+ uploadInput.value = '';
1950
+ if(!files.length){ status('Pick an image file to upload as a reference.', 'error'); return; }
1951
+ var original = uploadBtn.textContent;
1952
+ uploadBtn.disabled = true; uploadBtn.textContent = 'Uploading\u2026';
1953
+ addFiles(files).then(function(added){
1954
+ uploadBtn.disabled = false; uploadBtn.textContent = original;
1955
+ if(added) closePanel();
1956
+ else status('Those files were already attached.', '');
1957
+ });
1958
+ });
1959
+ var mountEl = document.createElement('div'); mountEl.className = 'rk-ip-filemodal-mount';
1960
+ panel.appendChild(head); panel.appendChild(mountEl); overlay.appendChild(panel);
1961
+ document.body.appendChild(overlay); modal = overlay;
1962
+ close.addEventListener('click', closePanel);
1963
+ overlay.addEventListener('click', function(e){ if(e.target === overlay) closePanel(); });
1964
+ document.addEventListener('keydown', onEsc, true);
1965
+ if(window.__vidfarmDirectory && window.__vidfarmDirectory.mount){
1966
+ modal._handle = window.__vidfarmDirectory.mount(mountEl, {
1967
+ mode: 'attach', showMultiSelect: true, primaryClick: 'attach',
1968
+ onAttach: function(items){
1969
+ var added = 0;
1970
+ (items || []).forEach(function(it){
1971
+ var ct = it.contentType || '';
1972
+ var isImg = ct.indexOf('image/') === 0 || /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(it.name || '');
1973
+ if(it.viewUrl && isImg && refs.indexOf(it.viewUrl) === -1){ refs.push(it.viewUrl); added++; }
1974
+ });
1975
+ renderRefs();
1976
+ if(added){ status(plural(added, 'reference') + ' attached.', 'success'); closePanel(); }
1977
+ else status('Pick an image file to use as a reference.', 'error');
1978
+ }
1979
+ });
1980
+ } else {
1981
+ mountEl.innerHTML = '<p style="padding:16px;color:var(--rk-text-muted);font-size:13px">File browser is unavailable right now.</p>';
1982
+ }
1983
+ }
1984
+
1985
+ // ---- wiring ------------------------------------------------------------
1986
+ if(ta){
1987
+ ta.addEventListener('input', function(){ autoGrow(); syncSend(); });
1988
+ ta.addEventListener('keydown', function(e){
1989
+ if(e.key !== 'Enter' || e.shiftKey) return;
1990
+ e.preventDefault();
1991
+ // Enter is ignored mid-turn so an impatient double-send cannot queue a
1992
+ // second job; the draft stays put.
1993
+ if(busy) return;
1994
+ if(cfg.onSend) cfg.onSend();
1995
+ });
1996
+ ta.addEventListener('paste', function(e){
1997
+ var imgs = imagesFromClipboard(e);
1998
+ if(imgs.length){ e.preventDefault(); addFiles(imgs); }
1999
+ });
2000
+ }
2001
+ if(clipBtn) clipBtn.addEventListener('click', openPanel);
2002
+ if(sendBtn) sendBtn.addEventListener('click', function(){
2003
+ if(busy){ if(cfg.onStop) cfg.onStop(); return; }
2004
+ if(cfg.onSend) cfg.onSend();
2005
+ });
2006
+ if(root){
2007
+ ['dragenter', 'dragover'].forEach(function(ev){
2008
+ root.addEventListener(ev, function(e){
2009
+ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); root.classList.add('is-dragref'); }
2010
+ });
2011
+ });
2012
+ ['dragleave', 'dragend'].forEach(function(ev){
2013
+ root.addEventListener(ev, function(){ root.classList.remove('is-dragref'); });
2014
+ });
2015
+ root.addEventListener('drop', function(e){
2016
+ if(!dtHasFiles(e)) return;
2017
+ e.preventDefault(); e.stopPropagation();
2018
+ root.classList.remove('is-dragref');
2019
+ addFiles(e.dataTransfer.files);
2020
+ });
2021
+ }
2022
+
2023
+ autoGrow();
2024
+ syncSend();
2025
+
2026
+ return {
2027
+ refs: function(){ return refs.slice(); },
2028
+ setRefs: function(list){ refs = (list || []).slice(); renderRefs(); },
2029
+ clearRefs: function(){ refs = []; renderRefs(); },
2030
+ addFiles: addFiles,
2031
+ renderRefs: renderRefs,
2032
+ autoGrow: autoGrow,
2033
+ resetHeight: resetHeight,
2034
+ setBusy: setBusy,
2035
+ isBusy: function(){ return busy; },
2036
+ syncSend: syncSend,
2037
+ closePanel: closePanel,
2038
+ setPlaceholder: function(s){ if(ta) ta.placeholder = s; }
2039
+ };
2040
+ };
2041
+ })();
2042
+
1676
2043
  (function(){
1677
2044
  var videoEl = document.querySelector("[data-ipv-video]");
1678
2045
  var canvas = document.querySelector("[data-ipv-canvas]");
@@ -1733,6 +2100,10 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1733
2100
  var PLAY_SVG = "<svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M7 5.5v13a1 1 0 0 0 1.5.87l11-6.5a1 1 0 0 0 0-1.74l-11-6.5A1 1 0 0 0 7 5.5Z\"/></svg>";
1734
2101
  var PAUSE_SVG = "<svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/><rect x=\"14\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/></svg>";
1735
2102
  var CLOCK_SVG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"10\" cy=\"10\" r=\"7\"/><path d=\"M10 6v4l2.6 1.6\"/></svg>";
2103
+ var EXPAND_SVG = "<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>";
2104
+ var SAVE_SVG = "<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>";
2105
+ var DL_SVG = "<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>";
2106
+ var COPY_SVG = "<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>";
1736
2107
 
1737
2108
  // ===== state =====
1738
2109
  var sourceVideoUrl = ""; // playable URL (may be a blob: object URL for uploads)
@@ -1797,7 +2168,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1797
2168
  sessionTracer = value; tracerAutoPending = false; if(tracerInput) tracerInput.value = value; return value;
1798
2169
  }
1799
2170
  function resolveTracer(){ if(sessionTracer) return sessionTracer; var v = tracerInput ? tracerInput.value.trim() : ""; return v; }
1800
- function getTracerValue(){ if(sessionTracer) return sessionTracer; var v = tracerInput ? tracerInput.value.trim() : ""; if(v){ sessionTracer = v; tracerAutoPending = false; return v; } return nameTracerFromSeed("video-edit"); }
2171
+ function getTracerValue(){ if(sessionTracer) return sessionTracer; var v = tracerInput ? tracerInput.value.trim() : ""; if(v){ sessionTracer = v; tracerAutoPending = false; return v; } return commitRandomTracer(); }
2172
+ // A session with nothing to name it from yet (e.g. Copy chat ID before the first
2173
+ // prompt) gets a plain id — seeding nameTracerFromSeed("video-edit") here produced
2174
+ // the doubled "video-edit-video-edit-…".
2175
+ function commitRandomTracer(){
2176
+ var value = "video-edit-" + tracerSuffix();
2177
+ sessionTracer = value; tracerAutoPending = false; if(tracerInput) tracerInput.value = value; return value;
2178
+ }
1801
2179
 
1802
2180
  // ===== provider list =====
1803
2181
  function loadProviders(){
@@ -1993,6 +2371,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1993
2371
  videoEl.onerror = function(){ if(!crossOriginRetried && videoEl.getAttribute("crossorigin")){ crossOriginRetried = true; videoEl.removeAttribute("crossorigin"); videoEl.src = url; videoEl.load(); } };
1994
2372
  videoEl.src = url;
1995
2373
  videoEl.load();
2374
+ ensureSourceTurn(url, name || "Video");
1996
2375
  setStatus("Video ready. Scrub the timeline and paint timed masks.", "success");
1997
2376
  }
1998
2377
  function loadSourceFile(file){
@@ -2005,6 +2384,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2005
2384
  videoEl.src = sourceObjectUrl;
2006
2385
  videoEl.addEventListener("loadedmetadata", function(){ syncCanvasSize(); if(durationEl) durationEl.textContent = "/ " + formatTimecode(videoEl.duration || 0); redraw(); }, { once: true });
2007
2386
  videoEl.load();
2387
+ ensureSourceTurn(sourceObjectUrl, file.name || "Uploaded video");
2008
2388
  setStatus("Video loaded. Scrub the timeline and paint timed masks.", "success");
2009
2389
  }
2010
2390
 
@@ -2186,6 +2566,20 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2186
2566
  var path = String((entry && entry.path) || "").toLowerCase();
2187
2567
  return tid === "primitive:video_generate" || path.indexOf("/primitives/videos/generate") >= 0 || path.indexOf("/primitives/video_generate") >= 0;
2188
2568
  }
2569
+ // The user's message for a run-history entry. New turns carry chat_message on the
2570
+ // envelope; older ones are un-composed back out of the instruction we built.
2571
+ function userTextFromEntry(entry){
2572
+ var body = (entry && entry.request && entry.request.body) || {};
2573
+ if(typeof body.chat_message === "string" && body.chat_message.trim()) return body.chat_message.trim();
2574
+ var payload = body.payload || {};
2575
+ var raw = String(payload.prompt || payload.instruction || "");
2576
+ if(!raw) return "";
2577
+ var whole = raw.split("Whole clip: ")[1];
2578
+ if(whole) return whole.split(". Timed region edits:")[0].split(". Match the surrounding lighting")[0].trim();
2579
+ var timed = raw.split("Timed region edits: ")[1];
2580
+ if(timed) return timed.split(". Match the surrounding lighting")[0].trim();
2581
+ return raw.length > 240 ? raw.slice(0, 240) + "\u2026" : raw;
2582
+ }
2189
2583
  function extractHistoryItems(entries){
2190
2584
  var items = []; var seen = {};
2191
2585
  (entries || []).forEach(function(entry){
@@ -2204,16 +2598,46 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2204
2598
  });
2205
2599
  return items;
2206
2600
  }
2601
+ // Set while opening a past session: the next history load restores that session's
2602
+ // conversation state (its last prompt back in the composer, its newest video in the
2603
+ // Result panel) instead of just repainting the rail.
2604
+ var sessionRestorePending = false;
2605
+ function restoreSessionFromEntries(entries){
2606
+ var rows = (entries || []).filter(isVideoEntry)
2607
+ .sort(function(a, b){ return Date.parse(a.createdAt || 0) - Date.parse(b.createdAt || 0); });
2608
+ if(!rows.length){ setStatus("Opened " + resolveTracer() + " \u2014 no messages in it yet.", ""); return; }
2609
+ // Replay the whole thread: each history entry is one user message and, when
2610
+ // it produced output, the clip that answered it.
2611
+ chatTurns = []; activeTurnId = ""; turnSeq = 0;
2612
+ var newestUrl = "";
2613
+ rows.forEach(function(row){
2614
+ var msg = userTextFromEntry(row);
2615
+ if(msg) chatTurns.push({ id: nextTurnId(), at: row.createdAt || nowIso(), role: "user", kind: "text", text: msg, refs: [], isEdit: false, masked: false });
2616
+ var urls = historyEntryUrls(row);
2617
+ if(!urls[0]) return;
2618
+ var abs; try { abs = absoluteUrl(urls[0]); } catch(_){ return; }
2619
+ newestUrl = abs;
2620
+ chatTurns.push({ id: nextTurnId(), at: row.createdAt || nowIso(), role: "assistant", kind: "media", url: abs });
2621
+ });
2622
+ if(newestUrl){
2623
+ try { resultVideo.src = newestUrl; outputPanel.hidden = false; } catch(_){}
2624
+ // Make it the source so the next message edits it and it gets the player.
2625
+ loadSourceUrl(newestUrl, "Session video");
2626
+ }
2627
+ renderChat();
2628
+ setStatus("Session restored \u2014 " + rows.length + " message" + (rows.length === 1 ? "" : "s") + ". Keep editing to pick up where you left off.", "success");
2629
+ }
2207
2630
  function loadHistory(){
2208
2631
  var tracer = resolveTracer();
2209
- if(!tracer){ historyItems = []; reinjectSources(); renderHistory(); return; }
2632
+ if(!tracer){ historyItems = []; reinjectSources(); renderHistory(); sessionRestorePending = false; return; }
2210
2633
  if(historyRefresh) historyRefresh.disabled = true;
2211
2634
  var endpoint = new URL("/job-runs/history", window.location.origin);
2212
2635
  endpoint.searchParams.set("limit", "120"); endpoint.searchParams.set("template_id", "primitive:video_generate"); endpoint.searchParams.set("tracer", tracer);
2213
2636
  fetch(endpoint, { credentials: "same-origin" }).then(function(r){ return parseJsonResponse(r).then(function(p){ return { ok: r.ok, p: p }; }); }).then(function(res){
2214
2637
  if(!res.ok || (res.p && res.p.error)) throw new Error((res.p && res.p.error) || "Unable to load history.");
2215
2638
  historyItems = extractHistoryItems(res.p.entries); reinjectSources(); renderHistory();
2216
- }).catch(function(err){ setHistoryEmpty(err && err.message ? err.message : "Unable to load history."); }).then(function(){ if(historyRefresh) historyRefresh.disabled = false; });
2639
+ if(sessionRestorePending){ sessionRestorePending = false; restoreSessionFromEntries(res.p.entries); }
2640
+ }).catch(function(err){ sessionRestorePending = false; setHistoryEmpty(err && err.message ? err.message : "Unable to load history."); }).then(function(){ if(historyRefresh) historyRefresh.disabled = false; });
2217
2641
  }
2218
2642
  function applyCompletedJob(payload){
2219
2643
  var out = extractVideoUrl(payload); if(!out) return "";
@@ -2257,29 +2681,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2257
2681
  if(frames.last && frames.last.url) arr.push({ image_url: frames.last.url, frame_type: "last_frame" });
2258
2682
  return arr;
2259
2683
  }
2260
- function setSubmitLoading(loading){ submitBtn.setAttribute("data-loading", loading ? "true" : "false"); submitBtn.disabled = loading; if(submitLabel) submitLabel.textContent = loading ? "Generating\u2026" : "Generate video"; }
2261
-
2262
- function submitGenerate(){
2263
- if(submitBtn.disabled) return;
2264
- var instruction = buildVideoInstruction();
2265
- if(!instruction){ setStatus("Add a video prompt or a mask instruction first.", "error"); return; }
2266
- var frameImgs = frameImagesPayload();
2267
- setSubmitLoading(true);
2268
- var seed = (globalPrompt && globalPrompt.value.trim()) || (masks.find(function(m){ return String(m.prompt || "").trim(); }) || {}).prompt || "video-edit";
2269
- var tracer = tracerAutoPending ? nameTracerFromSeed(seed) : getTracerValue();
2270
- var payload = { prompt: instruction, output_format: "mp4", aspect_ratio: getAspectRatio() };
2271
- if(frameImgs.length) payload.frame_images = frameImgs;
2272
- if(providerSelect && providerSelect.value) payload.provider = providerSelect.value;
2273
- if(durationSelect && durationSelect.value) payload.duration = Number(durationSelect.value);
2274
- if(audioToggle && audioToggle.checked) payload.generate_audio = true;
2275
- setStatus("Creating video job\u2026", "");
2276
- fetch("/api/v1/primitives/videos/generate", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ tracer: tracer, payload: payload }) })
2277
- .then(function(r){ return parseJsonResponse(r).then(function(p){ return { ok: r.ok, p: p }; }); })
2278
- .then(function(res){ if(!res.ok || !res.p.job_id) throw new Error(res.p.error || "Unable to create video job."); setStatus("Video job " + res.p.job_id + " started \u2014 generating\u2026", "success"); upsertHistory(makePending(res.p)); return pollJob(res.p.job_id); })
2279
- .then(function(){ loadHistory(); loadSessions(); })
2280
- .catch(function(err){ setStatus(err && err.message ? err.message : "Unable to create video.", "error"); })
2281
- .then(function(){ setSubmitLoading(false); });
2282
- }
2283
2684
 
2284
2685
  // ===== lightbox =====
2285
2686
  function openVideoPreview(url, label, downloadName){
@@ -2306,8 +2707,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2306
2707
  (res.p.entries || []).forEach(function(entry){
2307
2708
  if(!isVideoEntry(entry)) return; var tracer = String(entry.tracer || "").trim(); if(!tracer) return;
2308
2709
  var urls = historyEntryUrls(entry);
2309
- if(!groups[tracer]){ groups[tracer] = { tracer: tracer, thumb: "", count: 0, createdAt: entry.createdAt || "" }; order.push(tracer); }
2310
- var g = groups[tracer]; if(!g.thumb && urls[0]){ try { g.thumb = absoluteUrl(urls[0]); } catch(_){} } g.count += urls.length;
2710
+ if(!groups[tracer]){ groups[tracer] = { tracer: tracer, thumb: "", title: "", count: 0, createdAt: entry.createdAt || "", firstAt: entry.createdAt || "" }; order.push(tracer); }
2711
+ var g = groups[tracer];
2712
+ // One history entry = one message in this session's conversation.
2713
+ g.count += 1;
2714
+ if(!g.thumb && urls[0]){ try { g.thumb = absoluteUrl(urls[0]); } catch(_){} }
2715
+ // Oldest message names the session (same rule as /tools/image).
2716
+ if(!g.title || Date.parse(entry.createdAt || 0) <= Date.parse(g.firstAt || 0)){
2717
+ var text = userTextFromEntry(entry);
2718
+ if(text){ g.title = text.length > 60 ? text.slice(0, 60) + "\u2026" : text; g.firstAt = entry.createdAt || g.firstAt; }
2719
+ }
2311
2720
  if(Date.parse(entry.createdAt || 0) > Date.parse(g.createdAt || 0)) g.createdAt = entry.createdAt;
2312
2721
  });
2313
2722
  var list = order.map(function(t){ return groups[t]; }).sort(function(a, b){ return Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0); });
@@ -2324,16 +2733,20 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2324
2733
  if(group.thumb){ var v = document.createElement("video"); v.className = "rk-ip-session-thumb"; v.src = group.thumb + "#t=0.1"; v.muted = true; v.preload = "metadata"; row.appendChild(v); }
2325
2734
  else { var ph = document.createElement("span"); ph.className = "rk-ip-session-thumb is-empty"; ph.innerHTML = PLAY_SVG; row.appendChild(ph); }
2326
2735
  var meta = document.createElement("div"); meta.className = "rk-ip-session-meta";
2327
- var title = document.createElement("div"); title.className = "rk-ip-session-title"; title.textContent = group.tracer;
2736
+ var title = document.createElement("div"); title.className = "rk-ip-session-title"; title.textContent = group.title || group.tracer;
2737
+ title.title = group.title ? group.title + " (" + group.tracer + ")" : group.tracer;
2328
2738
  var sub = document.createElement("div"); sub.className = "rk-ip-session-sub"; var when = relTimeShort(group.createdAt);
2329
- sub.textContent = (group.count ? group.count + " video" + (group.count === 1 ? "" : "s") : "session") + (when ? " \u00b7 " + when : "");
2739
+ sub.textContent = (group.count ? group.count + " message" + (group.count === 1 ? "" : "s") : "session") + (when ? " \u00b7 " + when : "");
2330
2740
  meta.appendChild(title); meta.appendChild(sub); row.appendChild(meta); sessionsList.appendChild(row);
2331
2741
  });
2332
2742
  }
2333
2743
  function switchToTracer(tracer){
2334
2744
  var value = String(tracer || "").trim(); if(!value) return;
2335
- sessionTracer = value; if(tracerInput) tracerInput.value = value; tracerAutoPending = false; closeSessions(); loadHistory(); loadSessions();
2336
- setStatus("Opened session " + value + ".", "");
2745
+ clearUrlParams(); resetWorkspace();
2746
+ sessionTracer = value; if(tracerInput) tracerInput.value = value; tracerAutoPending = false; closeSessions();
2747
+ setStatus("Opening " + value + "\u2026", "");
2748
+ sessionRestorePending = true;
2749
+ loadHistory(); loadSessions();
2337
2750
  }
2338
2751
 
2339
2752
  // ===== reset =====
@@ -2344,6 +2757,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2344
2757
  ["first","last"].forEach(function(w){ var img = document.querySelector('[data-ipv-frame-img="' + w + '"]'); var ph = document.querySelector('[data-ipv-frame-ph="' + w + '"]'); if(img){ img.hidden = true; img.removeAttribute("src"); } if(ph) ph.style.display = ""; });
2345
2758
  if(globalPrompt) globalPrompt.value = ""; attachments = []; renderAttachChips();
2346
2759
  outputPanel.hidden = true; resultVideo.removeAttribute("src");
2760
+ chatTurns = []; activeTurnId = ""; turnSeq = 0; renderChat();
2347
2761
  emptyState.hidden = false; if(player) player.hidden = true;
2348
2762
  canvas.width = 1; canvas.height = 1; renderMasks();
2349
2763
  if(scrub) scrub.value = "0"; if(timecodeInput) timecodeInput.value = "00:00:000"; if(durationEl) durationEl.textContent = "/ 00:00:000";
@@ -2378,13 +2792,19 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2378
2792
  if(brushInput){ brushInput.addEventListener("input", function(){ if(brushLabel) brushLabel.textContent = brushInput.value; updateBrushCursorSize(); }); }
2379
2793
  if(uploadBtn && fileInput){ uploadBtn.addEventListener("click", function(){ fileInput.click(); }); fileInput.addEventListener("change", function(){ if(fileInput.files && fileInput.files[0]) loadSourceFile(fileInput.files[0]); }); }
2380
2794
  if(startNewBtn) startNewBtn.addEventListener("click", startNew);
2795
+ // Copy chat ID — the session tracer IS this chat's thread id (what /job-runs/history
2796
+ // groups on and what ?tracer= reopens). An unnamed session mints its id here.
2797
+ var copyChatBtn = document.querySelector("[data-ipv-copy-chat]");
2798
+ if(copyChatBtn) copyChatBtn.addEventListener("click", function(){
2799
+ var tracer = resolveTracer() || getTracerValue();
2800
+ copyText(tracer).then(function(ok){ setStatus(ok ? "Chat ID copied \u2014 " + tracer : "Chat ID: " + tracer, ok ? "success" : ""); });
2801
+ });
2381
2802
  if(addMaskBtn) addMaskBtn.addEventListener("click", function(){ addMask(videoEl.currentTime || 0, colorPicker ? colorPicker.value : null); });
2382
2803
  if(colorPicker) colorPicker.addEventListener("input", function(){ var m = activeMask(); if(m){ m.color = normalizeHex(colorPicker.value); renderMasks(); redraw(); } });
2383
2804
  if(undoBtn) undoBtn.addEventListener("click", function(){ var m = activeMask(); if(m && m.strokes.length){ m.redo.push(m.strokes.pop()); redraw(); syncUndoRedo(); } });
2384
2805
  if(redoBtn) redoBtn.addEventListener("click", function(){ var m = activeMask(); if(m && m.redo.length){ m.strokes.push(m.redo.pop()); redraw(); syncUndoRedo(); } });
2385
2806
  if(clearBtn) clearBtn.addEventListener("click", function(){ var m = activeMask(); if(m){ m.redo = m.strokes.concat(m.redo); m.strokes = []; redraw(); syncUndoRedo(); } });
2386
2807
  if(attachBtn && attachInput){ attachBtn.addEventListener("click", function(){ attachInput.click(); }); attachInput.addEventListener("change", function(){ addAttachmentFiles(attachInput.files); attachInput.value = ""; }); }
2387
- if(submitBtn) submitBtn.addEventListener("click", submitGenerate);
2388
2808
 
2389
2809
  masksList.addEventListener("click", function(event){
2390
2810
  var t = event.target; if(!(t instanceof Element)) return;
@@ -2470,94 +2890,345 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2470
2890
  }
2471
2891
 
2472
2892
  // ===== text-to-video composer =====
2893
+ // ===== chat feed + shared composer =====
2473
2894
  var genPrompt = document.getElementById("rk-ipv-genprompt");
2474
2895
  var genClip = document.getElementById("rk-ipv-genclip");
2475
2896
  var genSend = document.getElementById("rk-ipv-gensend");
2476
2897
  var genRefsEl = document.getElementById("rk-ipv-genrefs");
2477
2898
  var genStatusEl = document.getElementById("rk-ipv-genstatus");
2478
- var genWrap = document.getElementById("rk-ipv-gen");
2479
- var GEN_SEND_HTML = genSend ? genSend.innerHTML : "";
2480
- var genRefs = []; var genBusy = false; var genFileModal = null;
2481
- function setGenStatus(m, t){ if(!genStatusEl) return; genStatusEl.textContent = m || ""; genStatusEl.setAttribute("data-tone", t || ""); }
2482
- function syncGenSend(){ if(genPrompt) genPrompt.disabled = genBusy; if(genClip) genClip.disabled = genBusy; if(!genSend) return; if(genBusy){ genSend.classList.add("is-busy"); genSend.innerHTML = "<span class='rk-ip-gen-spin'></span>"; } else { genSend.classList.remove("is-busy"); genSend.innerHTML = GEN_SEND_HTML; } genSend.disabled = genBusy || !(genPrompt && genPrompt.value.trim()); }
2483
- function renderGenRefs(){ if(!genRefsEl) return; genRefsEl.innerHTML = ""; if(!genRefs.length){ genRefsEl.hidden = true; return; } genRefsEl.hidden = false; genRefs.forEach(function(url, idx){ var chip = document.createElement("span"); chip.className = "rk-ip-gen-ref"; var img = document.createElement("img"); img.src = url; img.alt = ""; chip.appendChild(img); var label = document.createElement("span"); label.textContent = sourceNameFromUrl(url); chip.appendChild(label); var x = document.createElement("button"); x.type = "button"; x.textContent = "\u00d7"; x.addEventListener("click", function(){ genRefs.splice(idx, 1); renderGenRefs(); }); chip.appendChild(x); genRefsEl.appendChild(chip); }); }
2484
- function closeGenFilePanel(){ if(genFileModal){ try { genFileModal._handle && genFileModal._handle.destroy && genFileModal._handle.destroy(); } catch(_){} genFileModal.remove(); genFileModal = null; document.removeEventListener("keydown", onGenFileEsc, true); } }
2485
- function onGenFileEsc(e){ if(e.key === "Escape") closeGenFilePanel(); }
2486
- function openGenFilePanel(){
2487
- var dir = window.__vidfarmDirectory; if(!dir || !dir.pick){ setGenStatus("File browser isn't available right now.", "error"); return; }
2488
- dir.pick({ select: "file", roots: ["files","temp","raws"], title: "Attach reference images", hint: "Pick one or more images to guide the generation.", confirmLabel: "Attach", multiple: true,
2489
- onPick: function(picked){ var arr = Array.isArray(picked) ? picked : [picked]; arr.forEach(function(f){ var url = f && (f.url || f.viewUrl); if(url){ try { genRefs.push(absoluteUrl(url)); } catch(_){} } }); renderGenRefs(); } });
2490
- }
2491
- // Paste or drop image files straight into the composer -> temp upload -> reference.
2492
- function addGenReferenceFiles(fileList){
2493
- 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 || ""); });
2494
- if(!files.length) return Promise.resolve(0);
2495
- setGenStatus("Uploading " + files.length + " reference" + (files.length > 1 ? "s" : "") + "\u2026", "");
2496
- var added = 0;
2497
- return files.reduce(function(chain, f){
2498
- return chain.then(function(){
2499
- return uploadTemporaryBlob(f, f.name || ("pasted-image.png"), f.type || "image/png").then(function(uploaded){
2500
- if(uploaded){ var url = absoluteUrl(uploaded); if(genRefs.indexOf(url) === -1){ genRefs.push(url); added++; renderGenRefs(); } }
2501
- });
2502
- });
2503
- }, Promise.resolve()).then(function(){
2504
- if(added) setGenStatus(added + " reference" + (added > 1 ? "s" : "") + " attached.", "success");
2505
- return added;
2506
- }).catch(function(err){ setGenStatus((err && err.message) ? err.message : "Upload failed.", "error"); return added; });
2899
+ var composerEl = document.querySelector("[data-ip-composer]");
2900
+ var chatFeed = document.querySelector("[data-ipv-feed]");
2901
+ var chatEmpty = document.querySelector("[data-ipv-empty]");
2902
+ var canvasHolder = document.querySelector("[data-ipv-canvas-holder]");
2903
+ var playerStack = document.querySelector("[data-ipv-player]");
2904
+ var compStatusEl = document.querySelector("[data-ipv-comp-status]");
2905
+
2906
+ function setGenStatus(m, t){ setStatus(m, t); }
2907
+ function maskHasInstruction(){ return masks.some(function(m){ return String(m.prompt || "").trim() && (m.strokes || []).length; }); }
2908
+
2909
+ // The composer is the shared implementation from chat-composer.ts the exact
2910
+ // same auto-grow, drag-to-resize grip, chips, paste/drop and attach panel as
2911
+ // /tools/image.
2912
+ var composer = window.__rkChatComposer({
2913
+ root: composerEl,
2914
+ textarea: genPrompt,
2915
+ refsEl: genRefsEl,
2916
+ sendBtn: genSend,
2917
+ clipBtn: genClip,
2918
+ attachTitle: "Attach reference images",
2919
+ uploadBlob: function(f, name, type){ return uploadTemporaryBlob(f, name, type); },
2920
+ onStatus: function(m, t){ setStatus(m, t); },
2921
+ canSend: function(){ return maskHasInstruction(); },
2922
+ onSend: function(){ sendChatMessage(); },
2923
+ onStop: function(){ stopActiveTurn(); }
2924
+ });
2925
+ // ---- quick actions -----------------------------------------------------
2926
+ // Upload Video swaps the clip being edited; Upload Storyboard is just a
2927
+ // reference image (it rides along as input_references, it does NOT split the
2928
+ // clip into per-panel shots).
2929
+ var quickVideoBtn = document.querySelector("[data-ipv-quick-video]");
2930
+ var quickStoryBtn = document.querySelector("[data-ipv-quick-storyboard]");
2931
+ var storyboardInput = document.querySelector("[data-ipv-storyboard-input]");
2932
+ if(quickVideoBtn && fileInput) quickVideoBtn.addEventListener("click", function(){ fileInput.click(); });
2933
+ if(quickStoryBtn && storyboardInput) quickStoryBtn.addEventListener("click", function(){ storyboardInput.click(); });
2934
+ if(storyboardInput) storyboardInput.addEventListener("change", function(){
2935
+ var files = Array.prototype.slice.call(storyboardInput.files || []);
2936
+ storyboardInput.value = "";
2937
+ if(!files.length) return;
2938
+ // Same funnel as paste/drop — upload, then show as a composer chip.
2939
+ composer.addFiles(files);
2940
+ });
2941
+
2942
+ function renderGenRefs(){ composer.renderRefs(); }
2943
+ function syncGenSend(){ composer.syncSend(); }
2944
+
2945
+ // ===== turn model =====
2946
+ var chatTurns = [];
2947
+ var activeTurnId = "";
2948
+ var turnSeq = 0;
2949
+ function nextTurnId(){ turnSeq += 1; return "t" + turnSeq; }
2950
+ function addTurn(turn){
2951
+ var item = Object.assign({ id: nextTurnId(), at: nowIso(), role: "assistant" }, turn);
2952
+ chatTurns.push(item); renderChat(); return item;
2953
+ }
2954
+ function updateTurn(id, patch){
2955
+ if(!id) return;
2956
+ chatTurns = chatTurns.map(function(t){ return t.id === id ? Object.assign({}, t, patch) : t; });
2957
+ renderChat();
2958
+ }
2959
+ // Patch pending labels in place — a full re-render would restart a clip that is
2960
+ // playing further up the feed.
2961
+ function updatePendingLabel(id, label){
2962
+ if(!id) return;
2963
+ chatTurns = chatTurns.map(function(t){ return t.id === id ? Object.assign({}, t, { label: label }) : t; });
2964
+ var node = chatFeed && chatFeed.querySelector("[data-pending-id='" + id + "']");
2965
+ if(node) node.textContent = label;
2966
+ }
2967
+ function resolveActiveTurn(patch){
2968
+ if(activeTurnId){ updateTurn(activeTurnId, patch); activeTurnId = ""; return; }
2969
+ addTurn(Object.assign({ role: "assistant" }, patch));
2970
+ }
2971
+ function turnById(id){ return chatTurns.filter(function(t){ return t.id === id; })[0] || null; }
2972
+ // A clip that arrives WITHOUT a message (an upload, a ?source_video_url deep
2973
+ // link) still needs a bubble — renderChat only hosts the player inside a media
2974
+ // turn whose url is the current source.
2975
+ function ensureSourceTurn(url, name){
2976
+ if(!url) return;
2977
+ var already = chatTurns.some(function(t){ return t.kind === "media" && t.url === url; });
2978
+ if(already){ renderChat(); return; }
2979
+ addTurn({ role: "assistant", kind: "media", url: url, note: name || "" });
2980
+ }
2981
+
2982
+ // ===== stop =====
2983
+ var activeJob = { kind: "", jobId: "" };
2984
+ var stopRequested = false;
2985
+ function stopError(){ var e = new Error("__vidfarm_stopped__"); e.stopped = true; return e; }
2986
+ function throwIfStopped(){ if(stopRequested) throw stopError(); }
2987
+ function stopActiveTurn(){
2988
+ if(!composer.isBusy() || stopRequested) return;
2989
+ stopRequested = true;
2990
+ setStatus("Stopping\u2026", "");
2991
+ if(activeTurnId) updatePendingLabel(activeTurnId, "Stopping\u2026");
2992
+ if(activeJob.kind && activeJob.jobId){
2993
+ fetch("/api/v1/primitives/" + activeJob.kind + "/jobs/" + encodeURIComponent(activeJob.jobId) + "/cancel", {
2994
+ method: "POST", credentials: "same-origin"
2995
+ }).catch(function(){});
2996
+ }
2507
2997
  }
2508
- function imagesFromClipboard(e){
2509
- var items = (e.clipboardData && e.clipboardData.items) || []; var out = [];
2510
- 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); } }
2511
- return out;
2998
+
2999
+ // ===== feed rendering =====
3000
+ function mediaActionsRow(turn){
3001
+ var row = document.createElement("div");
3002
+ row.className = "rk-ip-media-actions";
3003
+ var full = document.createElement("button");
3004
+ full.type = "button"; full.className = "rk-ip-act"; full.dataset.ipvOpen = turn.id;
3005
+ full.setAttribute("aria-label", "Open fullscreen"); full.title = "Fullscreen";
3006
+ full.innerHTML = EXPAND_SVG;
3007
+ var download = document.createElement("a");
3008
+ download.className = "rk-ip-act"; download.href = turn.url; download.target = "_blank"; download.rel = "noopener noreferrer";
3009
+ download.setAttribute("download", videoDownloadName("video-chat", turn.at));
3010
+ download.setAttribute("aria-label", "Download this clip"); download.title = "Download";
3011
+ download.innerHTML = DL_SVG;
3012
+ var copy = document.createElement("button");
3013
+ copy.type = "button"; copy.className = "rk-ip-act"; copy.dataset.ipvCopy = turn.id;
3014
+ copy.setAttribute("aria-label", "Copy the file URL"); copy.title = "Copy URL";
3015
+ copy.innerHTML = COPY_SVG;
3016
+ var save = document.createElement("button");
3017
+ save.type = "button"; save.className = "rk-ip-act"; save.dataset.ipvSave = turn.id;
3018
+ save.setAttribute("aria-label", "Save to My Files"); save.title = "Save to My Files";
3019
+ save.innerHTML = SAVE_SVG;
3020
+ row.append(full, download, copy, save);
3021
+ return row;
2512
3022
  }
2513
- function dtHasFiles(e){ var t = (e.dataTransfer && e.dataTransfer.types) || []; return Array.prototype.indexOf.call(t, "Files") !== -1; }
2514
- function pollGenerateJob(jobId){
2515
- var attempt = 0;
2516
- function step(){
2517
- return fetch("/api/v1/primitives/video_generate/jobs/" + encodeURIComponent(jobId), { credentials: "same-origin" }).then(function(r){ return parseJsonResponse(r).then(function(p){ return { ok: r.ok, p: p }; }); }).then(function(res){
2518
- var payload = res.p; if(!res.ok) throw new Error(payload.error || "Unable to poll job.");
2519
- if(payload.status === "failed" || payload.status === "cancelled") throw new Error((payload.error && payload.error.message) || ("Video job " + payload.status + "."));
2520
- var out = extractVideoUrl(payload);
2521
- if(payload.status === "succeeded" || payload.status === "complete" || out){ if(!out) throw new Error("Completed without an output URL."); return absoluteUrl(out); }
2522
- setGenStatus("Generating video (" + (payload.status || "running") + ")\u2026 this can take a few minutes", "");
2523
- attempt++; if(attempt >= 120){ setGenStatus("Still running \u2014 check History shortly.", ""); return ""; }
2524
- return new Promise(function(res2){ setTimeout(res2, attempt < 8 ? 2000 : 4000); }).then(step);
2525
- });
3023
+
3024
+ function renderChat(){
3025
+ if(!chatFeed) return;
3026
+ // Park the player before the innerHTML rebuild, or it would be destroyed.
3027
+ if(playerStack && playerStack.parentElement !== canvasHolder && canvasHolder) canvasHolder.appendChild(playerStack);
3028
+ var wasNearBottom = (chatFeed.scrollHeight - chatFeed.scrollTop - chatFeed.clientHeight) < 120;
3029
+ chatFeed.innerHTML = "";
3030
+ if(!chatTurns.length){
3031
+ if(chatEmpty){ chatEmpty.hidden = false; chatFeed.appendChild(chatEmpty); }
3032
+ return;
2526
3033
  }
2527
- return step();
3034
+ if(chatEmpty) chatEmpty.hidden = true;
3035
+ // The newest clip that IS the current source gets the scrubbable player, so
3036
+ // timed masks are always painted on the clip the next message will edit.
3037
+ var latestPlayableId = "";
3038
+ for(var i = chatTurns.length - 1; i >= 0; i--){
3039
+ var c = chatTurns[i];
3040
+ if(c.kind === "media" && c.url && c.url === sourceVideoUrl){ latestPlayableId = c.id; break; }
3041
+ }
3042
+ chatTurns.forEach(function(turn){
3043
+ var row = document.createElement("div");
3044
+ row.className = "rk-ip-turn";
3045
+ row.dataset.role = turn.role; row.dataset.kind = turn.kind; row.dataset.turnId = turn.id;
3046
+ if(turn.role === "user"){
3047
+ if(turn.refs && turn.refs.length){
3048
+ var refs = document.createElement("div"); refs.className = "rk-ip-turn-refs";
3049
+ turn.refs.forEach(function(url){ var im = document.createElement("img"); im.src = url; im.alt = ""; im.loading = "lazy"; refs.appendChild(im); });
3050
+ row.appendChild(refs);
3051
+ }
3052
+ var tag = document.createElement("span");
3053
+ tag.className = "rk-ip-turn-tag";
3054
+ tag.textContent = turn.masked ? "Masked edit" : (turn.isEdit ? "Edit" : "New video");
3055
+ row.appendChild(tag);
3056
+ var bubble = document.createElement("div"); bubble.className = "rk-ip-bubble";
3057
+ bubble.textContent = turn.text || ""; row.appendChild(bubble);
3058
+ } else if(turn.kind === "pending"){
3059
+ var pending = document.createElement("div"); pending.className = "rk-ip-pending";
3060
+ pending.dataset.pendingId = turn.id; pending.textContent = turn.label || "Working\u2026";
3061
+ row.appendChild(pending);
3062
+ } else if(turn.kind === "error" || turn.kind === "stopped"){
3063
+ var err = document.createElement("div"); err.className = "rk-ip-bubble";
3064
+ err.textContent = turn.text || "Something went wrong."; row.appendChild(err);
3065
+ } else if(turn.kind === "media" && turn.url){
3066
+ var media = document.createElement("div"); media.className = "rk-ip-media";
3067
+ var isLatest = turn.id === latestPlayableId;
3068
+ var frame = document.createElement("div");
3069
+ frame.className = "rk-ip-media-frame" + (isLatest ? " is-latest" : "");
3070
+ if(isLatest && playerStack){
3071
+ playerStack.hidden = false;
3072
+ frame.appendChild(playerStack);
3073
+ } else {
3074
+ var v = document.createElement("video");
3075
+ v.src = turn.url; v.controls = true; v.playsInline = true; v.preload = "metadata";
3076
+ frame.appendChild(v);
3077
+ }
3078
+ media.appendChild(frame);
3079
+ var actions = mediaActionsRow(turn);
3080
+ var note = document.createElement("span"); note.className = "rk-ip-act-note";
3081
+ note.textContent = isLatest
3082
+ ? (masks.length ? "Newest \u00b7 timed masks active" : "Newest \u00b7 scrub and paint a timed mask")
3083
+ : (turn.note || "");
3084
+ if(note.textContent) actions.appendChild(note);
3085
+ media.appendChild(actions);
3086
+ row.appendChild(media);
3087
+ } else {
3088
+ var text = document.createElement("div"); text.className = "rk-ip-bubble";
3089
+ text.textContent = turn.text || ""; row.appendChild(text);
3090
+ }
3091
+ chatFeed.appendChild(row);
3092
+ });
3093
+ if(wasNearBottom) chatFeed.scrollTop = chatFeed.scrollHeight;
2528
3094
  }
2529
- function submitGenerateNew(){
2530
- var p = (genPrompt && genPrompt.value || "").trim();
2531
- if(!p){ setGenStatus("Describe the video to generate.", "error"); if(genPrompt) genPrompt.focus(); return; }
2532
- if(genBusy) return; genBusy = true; syncGenSend(); if(genWrap) genWrap.classList.add("is-busy");
2533
- setGenStatus("Creating video\u2026", "");
2534
- var payload = { prompt: p, output_format: "mp4" };
2535
- var frameImgs = frameImagesPayload(); if(frameImgs.length) payload.frame_images = frameImgs;
2536
- if(genRefs.length) payload.input_references = genRefs.slice();
3095
+
3096
+ if(chatFeed){
3097
+ chatFeed.addEventListener("click", function(event){
3098
+ var target = event.target;
3099
+ if(!(target instanceof Element)) return;
3100
+ var open = target.closest("[data-ipv-open]");
3101
+ if(open instanceof HTMLElement){
3102
+ var t = turnById(open.dataset.ipvOpen || "");
3103
+ if(t && t.url) openVideoPreview(t.url, "Video output", videoDownloadName("video-chat", t.at));
3104
+ return;
3105
+ }
3106
+ var copy = target.closest("[data-ipv-copy]");
3107
+ if(copy instanceof HTMLElement){
3108
+ var ct = turnById(copy.dataset.ipvCopy || "");
3109
+ if(ct && ct.url){ try { navigator.clipboard.writeText(ct.url); setStatus("File URL copied.", "success"); } catch(_){} }
3110
+ return;
3111
+ }
3112
+ var save = target.closest("[data-ipv-save]");
3113
+ if(save instanceof HTMLElement){
3114
+ var st = turnById(save.dataset.ipvSave || "");
3115
+ if(st && st.url) saveMediaToFiles(st.url, videoDownloadName("video-chat", st.at));
3116
+ }
3117
+ });
3118
+ }
3119
+ // ===== send =====
3120
+ // ONE send path for every message, so the page is a single conversation rather
3121
+ // than two disconnected surfaces. With a clip loaded, the message + timed masks
3122
+ // + frames compile into the rich instruction buildVideoInstruction() produces;
3123
+ // with no clip it is plain text->video. Both are POST /videos/generate.
3124
+ function sendChatMessage(){
3125
+ if(composer.isBusy()) return;
3126
+ var text = (genPrompt && genPrompt.value || "").trim();
3127
+ var masked = maskHasInstruction();
3128
+ if(!text && !masked){
3129
+ setStatus("Describe the video you want.", "error");
3130
+ if(genPrompt) genPrompt.focus();
3131
+ return;
3132
+ }
3133
+ var refs = composer.refs();
3134
+ var editing = Boolean(sourceVideoUrl);
3135
+ // The whole-clip prompt field is what buildVideoInstruction() compiles from,
3136
+ // so the composer text becomes that field for this turn.
3137
+ if(globalPrompt) globalPrompt.value = text;
3138
+ var instruction = editing ? (buildVideoInstruction() || text) : text;
3139
+ if(!instruction){ setStatus("Describe the video you want.", "error"); return; }
3140
+
3141
+ addTurn({
3142
+ role: "user", kind: "text",
3143
+ text: text || "Apply the painted mask instructions.",
3144
+ refs: refs, isEdit: editing, masked: masks.length > 0
3145
+ });
3146
+ if(genPrompt){ genPrompt.value = ""; composer.resetHeight(); }
3147
+ composer.clearRefs();
3148
+ stopRequested = false;
3149
+ activeJob = { kind: "", jobId: "" };
3150
+ composer.setBusy(true);
3151
+ var pending = addTurn({ role: "assistant", kind: "pending", label: "Creating video\u2026" });
3152
+ activeTurnId = pending.id;
3153
+ setStatus("Creating video\u2026", "");
3154
+
3155
+ var payload = { prompt: instruction, output_format: "mp4", aspect_ratio: getAspectRatio() };
3156
+ var frameImgs = frameImagesPayload();
3157
+ if(frameImgs.length) payload.frame_images = frameImgs;
3158
+ if(refs.length) payload.input_references = refs;
2537
3159
  if(providerSelect && providerSelect.value) payload.provider = providerSelect.value;
2538
3160
  if(durationSelect && durationSelect.value) payload.duration = Number(durationSelect.value);
2539
3161
  if(audioToggle && audioToggle.checked) payload.generate_audio = true;
2540
- var genTracer = tracerAutoPending ? nameTracerFromSeed(p) : getTracerValue();
2541
- fetch("/api/v1/primitives/videos/generate", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ tracer: genTracer, payload: payload }) })
3162
+ var seed = text || "video-edit";
3163
+ var tracer = tracerAutoPending ? nameTracerFromSeed(seed) : getTracerValue();
3164
+
3165
+ fetch("/api/v1/primitives/videos/generate", {
3166
+ method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" },
3167
+ body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: text, chat_mode: "video", chat_masked: masks.length > 0 })
3168
+ })
2542
3169
  .then(function(r){ return parseJsonResponse(r).then(function(j){ return { ok: r.ok, j: j }; }); })
2543
- .then(function(res){ if(!res.ok || !res.j.job_id) throw new Error(res.j.error || "Unable to start video generation."); upsertHistory(makePending(res.j)); return pollGenerateJob(res.j.job_id); })
2544
- .then(function(out){ if(out){ loadSourceUrl(out, "Generated video"); recordSourceHistoryItem(out, "Generated video"); setGenStatus("Video generated. Scrub and paint timed masks to refine.", "success"); setStatus("Generated video loaded.", "success"); loadHistory(); } })
2545
- .catch(function(err){ var msg = (err && err.message) ? err.message : "Unable to generate video."; if(/401|403|sign in|unauth/i.test(msg)) msg = "Sign in to generate videos."; setGenStatus(msg, "error"); })
2546
- .then(function(){ genBusy = false; if(genWrap) genWrap.classList.remove("is-busy"); syncGenSend(); });
2547
- }
2548
- if(genPrompt){
2549
- genPrompt.addEventListener("input", syncGenSend);
2550
- genPrompt.addEventListener("keydown", function(e){ if(e.key === "Enter" && !e.shiftKey){ e.preventDefault(); submitGenerateNew(); } });
2551
- genPrompt.addEventListener("paste", function(e){ var imgs = imagesFromClipboard(e); if(imgs.length){ e.preventDefault(); addGenReferenceFiles(imgs); } });
3170
+ .then(function(res){
3171
+ if(!res.ok || !res.j.job_id) throw new Error(res.j.error || "Unable to start video generation.");
3172
+ throwIfStopped();
3173
+ activeJob = { kind: "video_generate", jobId: String(res.j.job_id) };
3174
+ upsertHistory(makePending(res.j));
3175
+ return pollChatJob(res.j.job_id);
3176
+ })
3177
+ .then(function(out){
3178
+ if(!out) return;
3179
+ // Resolve the pending bubble into the clip FIRST — loadSourceUrl calls
3180
+ // ensureSourceTurn, which would otherwise add a second bubble for the
3181
+ // same clip because this turn is still "pending" at that moment.
3182
+ resolveActiveTurn({ kind: "media", url: out });
3183
+ // The new clip becomes the source, so the NEXT message edits it.
3184
+ loadSourceUrl(out, "Generated video");
3185
+ recordSourceHistoryItem(out, "Generated video");
3186
+ setStatus("Video ready \u2014 scrub and paint a timed mask to refine it.", "success");
3187
+ loadHistory(); loadSessions();
3188
+ })
3189
+ .catch(function(err){
3190
+ if(err && err.stopped){ resolveActiveTurn({ kind: "stopped", text: "Stopped." }); setStatus("Stopped.", ""); return; }
3191
+ var msg = (err && err.message) ? err.message : "Unable to generate video.";
3192
+ if(/401|403|sign in|unauth/i.test(msg)) msg = "Sign in to generate videos.";
3193
+ resolveActiveTurn({ kind: "error", text: msg });
3194
+ setStatus(msg, "error");
3195
+ })
3196
+ .then(function(){ composer.setBusy(false); stopRequested = false; activeJob = { kind: "", jobId: "" }; });
2552
3197
  }
2553
- if(genWrap){
2554
- ["dragenter","dragover"].forEach(function(ev){ genWrap.addEventListener(ev, function(e){ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); genWrap.classList.add("is-dragref"); } }); });
2555
- ["dragleave","dragend"].forEach(function(ev){ genWrap.addEventListener(ev, function(){ genWrap.classList.remove("is-dragref"); }); });
2556
- genWrap.addEventListener("drop", function(e){ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); genWrap.classList.remove("is-dragref"); addGenReferenceFiles(e.dataTransfer.files); } });
3198
+
3199
+ // Poll that reports progress into the pending bubble and honours Stop.
3200
+ function pollChatJob(jobId){
3201
+ var attempt = 0;
3202
+ function step(){
3203
+ throwIfStopped();
3204
+ return fetch("/api/v1/primitives/video_generate/jobs/" + encodeURIComponent(jobId), { credentials: "same-origin" })
3205
+ .then(function(r){ return parseJsonResponse(r).then(function(p){ return { ok: r.ok, p: p }; }); })
3206
+ .then(function(res){
3207
+ throwIfStopped();
3208
+ var payload = res.p;
3209
+ if(!res.ok) throw new Error(payload.error || "Unable to poll job.");
3210
+ if(payload.status === "failed" || payload.status === "cancelled") throw new Error((payload.error && payload.error.message) || ("Video job " + payload.status + "."));
3211
+ var out = extractVideoUrl(payload);
3212
+ if(payload.status === "succeeded" || payload.status === "complete" || out){
3213
+ if(!out) throw new Error("Completed without an output URL.");
3214
+ applyCompletedJob(payload);
3215
+ return absoluteUrl(out);
3216
+ }
3217
+ upsertHistory(makePending(payload));
3218
+ if(activeTurnId) updatePendingLabel(activeTurnId, "Generating video (" + (payload.status || "running") + ")\u2026 this can take a few minutes");
3219
+ attempt++;
3220
+ if(attempt >= 120){ setStatus("Still running \u2014 check History shortly.", ""); return ""; }
3221
+ return new Promise(function(res2){ setTimeout(res2, attempt < 8 ? 2000 : 4000); }).then(step);
3222
+ });
3223
+ }
3224
+ return step();
2557
3225
  }
2558
- if(genClip) genClip.addEventListener("click", openGenFilePanel);
2559
- if(genSend) genSend.addEventListener("click", submitGenerateNew);
2560
- renderGenRefs(); syncGenSend();
3226
+
3227
+ // The left panel's Generate button now routes through the same conversation.
3228
+ if(submitBtn) submitBtn.addEventListener("click", function(){ sendChatMessage(); });
3229
+ // Everything else on the composer (input, Enter, paste, drag-drop, paperclip,
3230
+ // send/stop) is wired by the shared composer runtime.
3231
+ renderChat();
2561
3232
 
2562
3233
  // ===== boot =====
2563
3234
  renderMasks();
@@ -3116,6 +3787,34 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3116
3787
  }
3117
3788
  function fileIcon(kind) { return kind === 'pdf' ? '📕' : kind === 'json' ? '{ }' : kind === 'markdown' ? '📝' : kind === 'audio' ? '🎵' : kind === 'text' ? '📄' : '📄'; }
3118
3789
 
3790
+ // ── "edit this media" hand-off: dock chat → the matching creative tool ──
3791
+ // Images open /tools/image, videos /tools/video, each with the file preloaded as
3792
+ // that tool's working file (its chat then edits THAT media). Deliberately a fresh
3793
+ // tool session — no ?tracer — so the tool names its own chat from the first prompt.
3794
+ 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>';
3795
+ function toolEditUrl(file) {
3796
+ if (!file || !file.viewUrl) return '';
3797
+ var k = fileKind(file.name, file.contentType);
3798
+ var name = file.name || '';
3799
+ if (k === 'image') return '/tools/image?source_image_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_image_name=' + encodeURIComponent(name) : '');
3800
+ if (k === 'video') return '/tools/video?source_video_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_video_name=' + encodeURIComponent(name) : '');
3801
+ return '';
3802
+ }
3803
+ function toolEditLink(file, className) {
3804
+ var href = toolEditUrl(file);
3805
+ if (!href) return null;
3806
+ var a = document.createElement('a');
3807
+ a.className = className;
3808
+ a.href = href;
3809
+ a.target = '_blank';
3810
+ a.rel = 'noopener noreferrer';
3811
+ a.title = 'Edit in the ' + (fileKind(file.name, file.contentType) === 'video' ? 'video' : 'image') + ' tool';
3812
+ a.setAttribute('aria-label', a.title);
3813
+ // The card itself opens the lightbox; the pencil must not also trigger it.
3814
+ a.addEventListener('click', function (e) { e.stopPropagation(); });
3815
+ return a;
3816
+ }
3817
+
3119
3818
  // Compact markdown → HTML for .md previews (headings, lists, quotes, rules,
3120
3819
  // fenced + inline code, bold/italic/links). Escapes first, then injects tags
3121
3820
  // (same order as linkify). Backticks are built via char codes to avoid
@@ -3176,7 +3875,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3176
3875
  var dlBtn = document.createElement('a'); dlBtn.className = 'rk-aichat-preview-act'; dlBtn.target = '_blank'; dlBtn.rel = 'noopener noreferrer'; dlBtn.textContent = 'Download';
3177
3876
  var linkBtn = document.createElement('button'); linkBtn.type = 'button'; linkBtn.className = 'rk-aichat-preview-act'; linkBtn.textContent = 'Copy link';
3178
3877
  var xBtn = document.createElement('button'); xBtn.type = 'button'; xBtn.className = 'rk-aichat-modal-x'; xBtn.setAttribute('aria-label', 'Close'); xBtn.innerHTML = '&times;';
3179
- head.appendChild(nameEl); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
3878
+ // Same hand-off as the card pencil, spelled out as a labelled action here.
3879
+ var editBtn = document.createElement('a'); editBtn.className = 'rk-aichat-preview-act'; editBtn.target = '_blank'; editBtn.rel = 'noopener noreferrer'; editBtn.textContent = 'Edit';
3880
+ head.appendChild(nameEl); head.appendChild(editBtn); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
3180
3881
  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); }
3181
3882
  // Copy = file text (json/md/txt) or its URL for binaries. Copy link = the
3182
3883
  // durable public S3 URL. Download opens in a new tab when the browser can't
@@ -3225,6 +3926,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3225
3926
  var it = items[idx];
3226
3927
  nameEl.textContent = it.name || 'file'; nameEl.title = it.name || '';
3227
3928
  dlBtn.href = it.viewUrl; dlBtn.setAttribute('download', it.name || '');
3929
+ var editHref = toolEditUrl(it);
3930
+ editBtn.hidden = !editHref;
3931
+ if (editHref) { editBtn.href = editHref; editBtn.title = 'Edit in the ' + (fileKind(it.name, it.contentType) === 'video' ? 'video' : 'image') + ' tool'; }
3228
3932
  linkBtn.style.display = it.publicUrl ? '' : 'none';
3229
3933
  body.innerHTML = '';
3230
3934
  var kind = fileKind(it.name, it.contentType);
@@ -3279,7 +3983,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3279
3983
  var files = [], seen = {};
3280
3984
  var arts = (job && job.artifacts) || [];
3281
3985
  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); } }
3282
- var mu = rkResultMediaUrl(job); if (mu && !seen[mu]) { seen[mu] = true; files.push({ name: (job.tracer || 'output'), contentType: '', viewUrl: mu, publicUrl: mu }); }
3986
+ // Name the direct result URL after the FILE, not the tracer fileKind() reads the
3987
+ // extension, and a tracer-named entry ("my-poster") classifies as 'other', losing
3988
+ // the image/video thumbnail and the edit-in-tool hand-off.
3989
+ var mu = rkResultMediaUrl(job);
3990
+ if (mu && !seen[mu]) {
3991
+ seen[mu] = true;
3992
+ var seg = String(mu).split('?')[0].split('/').pop() || '';
3993
+ files.push({ name: (/.[a-z0-9]{2,5}$/i.test(seg) ? seg : (job.tracer || 'output')), contentType: '', viewUrl: mu, publicUrl: mu });
3994
+ }
3283
3995
  var res = job && job.result;
3284
3996
  if (res && typeof res === 'object' && !Array.isArray(res) && Object.keys(res).length) {
3285
3997
  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) {}
@@ -3332,14 +4044,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3332
4044
  var grid = document.createElement('div'); grid.className = 'rk-aichat-files-grid';
3333
4045
  list.forEach(function (f, ix) {
3334
4046
  var k = fileKind(f.name, f.contentType);
3335
- var card = document.createElement('button'); card.type = 'button'; card.className = 'rk-aichat-file';
4047
+ // A div, not a button: media cards carry a nested "edit in tool" link, and
4048
+ // an anchor inside a button is invalid (and unclickable in some browsers).
4049
+ var card = document.createElement('div'); card.className = 'rk-aichat-file'; card.tabIndex = 0; card.setAttribute('role', 'button');
3336
4050
  var thumb = document.createElement('div'); thumb.className = 'rk-aichat-file-thumb rk-aichat-file-thumb--' + k;
3337
4051
  if (k === 'image') { var im = document.createElement('img'); im.src = f.viewUrl; im.alt = ''; im.loading = 'lazy'; thumb.appendChild(im); }
3338
4052
  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); }
3339
4053
  else { var ic = document.createElement('span'); ic.className = 'rk-aichat-file-ic'; ic.textContent = fileIcon(k); thumb.appendChild(ic); }
4054
+ var edit = toolEditLink(f, 'rk-aichat-file-open');
4055
+ if (edit) { edit.innerHTML = PENCIL_SVG; thumb.appendChild(edit); }
3340
4056
  var nm = document.createElement('div'); nm.className = 'rk-aichat-file-name'; nm.textContent = f.name || 'file'; nm.title = f.name || '';
3341
4057
  card.appendChild(thumb); card.appendChild(nm);
3342
4058
  card.addEventListener('click', function () { openFilePreview(list, ix); });
4059
+ card.addEventListener('keydown', function (e) {
4060
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFilePreview(list, ix); }
4061
+ });
3343
4062
  grid.appendChild(card);
3344
4063
  });
3345
4064
  wrap.appendChild(grid); turn.appendChild(wrap); logBottom();