# seedance-direct engine — free Higgsfield Seedance ($0/clip)

An **in-tree** provider engine that renders the `seedance-direct` route on Higgsfield's
free, unlimited *Enhanced Seedance 2.0 Fast* (`seedance_mini_unlimited`) through a stealth
browser (CloakBrowser), for **$0/clip**. videoclaw drives it as a subprocess: one JSON
object in on stdin, one clean JSON object out on stdout (all logs → stderr).

Origin: extracted from the external `higgs-cloak` repo and vendored here so videoclaw-v3
owns and versions it. See [ADR 0006 — vendor the seedance engine in-tree](https://github.com/davendra/videoclaw-v3/blob/main/docs/adr/0006-vendor-seedance-engine-in-tree.md)
and [`docs/adapters/higgs-cloak.md`](https://github.com/davendra/videoclaw-v3/blob/main/docs/adapters/higgs-cloak.md)
(ADR 0006 supersedes ADR 0005, which had kept it external). Both links are
absolute: `docs/adr/` and `docs/adapters/` live in the repository and are **not**
shipped inside the npm package, so a relative link from an installed copy
resolves to nothing.

## Runtime is NOT committed

The working runtime is git-ignored and bootstrapped per machine:
- `.venv/` — Python 3.14 venv (regenerable)
- `.cloak-profile/` — the **secret** ~679 MB logged-in Higgsfield stealth-browser session; **never commit it**.
- `.cloak-profile/.vclaw-engine-ready.json` — the **readiness file** for that session, written by
  `bootstrap/import_cookies.py` (see [How videoclaw selects this engine](#how-videoclaw-selects-this-engine)).

Only the code (`adapter.py`, `run.sh`, `bootstrap/`, `test_adapter.py`, `requirements.txt`) is versioned.

### Where the runtime lands, and how to move it

`bootstrap.sh` `cd`s to its own directory and creates **both** `.venv/` and
`.cloak-profile/` *inside this engine directory* — which, for an installed
package, is inside `node_modules/videoclaw/engines/seedance-direct/`. Two
consequences on a package install:

- A system-wide prefix needs write access there for the bootstrap to succeed.
- **`npm update` destroys both.** They are not backed up and the profile takes a
  browser login to rebuild.

`HIGGS_VCLAW_PROFILE` relocates the profile out of the package; export it
*before* `bootstrap/import_cookies.py` so the profile is written in the right
place the first time, and keep it exported for every run afterwards, because the
adapter reads the same variable to find the profile:

```sh
export HIGGS_VCLAW_PROFILE="$HOME/.local/share/videoclaw/cloak-profile"
```

There is currently **no override for the venv location** — it is always
`<engine>/.venv`. Re-running `bootstrap.sh` after an upgrade rebuilds it.

## Bootstrap (one time)

```sh
./bootstrap.sh                                   # venv + deps + `playwright install chromium`
./.venv/bin/python bootstrap/find_cookies.py     # locate the Chrome profile with higgsfield.ai cookies
./.venv/bin/python bootstrap/import_cookies.py   # write ./.cloak-profile + its marker  (opens a headless browser)
```

`bootstrap.sh` alone does **not** enable the engine: it installs dependencies and
cannot say whether the Higgsfield browser session is signed in, so it writes no
readiness file. `import_cookies.py` writes that file on every run carrying the
verdict it observed, and exits non-zero when the imported session did not reach
a signed-in page.
`import_cookies.py` defaults to Chrome "Profile 1"; override with `CHROME_COOKIE_FILE=…`.
The profile location can be overridden with `HIGGS_VCLAW_PROFILE=…` (default: `./.cloak-profile`)
— see [Where the runtime lands](#where-the-runtime-lands-and-how-to-move-it).

`import_cookies.py` also saves a proof screenshot, `higgsfield_loggedin.png`, into
the profile directory, so you can confirm by eye that the imported cookies really
land on a logged-in page. **Delete it once you have looked at it.** It is a
picture of an authenticated account page, it is not needed by anything at run
time, and on a default bootstrap it sits inside the installed package.

## How videoclaw selects this engine

`resolveAdapterCommand` (`src/video/execution-adapter.ts`) precedence for `seedance-direct`:

1. `VCLAW_SEEDANCE_DIRECT_ADAPTER` env override (an explicit command) — always wins.
2. **This engine** — the default **once fully bootstrapped**, unless
   `VCLAW_SEEDANCE_DIRECT_NATIVE=1` is set. A half-bootstrap falls through to paid, so it
   never fails at render time.
3. Built-in **paid** native transport (`native-seedance.ts` → `api.xskill.ai`, needs `SUTUI_API_KEY`) — the fallback when this engine isn't bootstrapped.

### The readiness file — `.vclaw-engine-ready.json`

"Fully bootstrapped" is decided by `describeFreeInTreeSeedanceEngine`
(`src/video/execution-adapter.ts`), which requires ALL of:

- `VCLAW_SEEDANCE_DIRECT_NATIVE` unset;
- `./.venv/bin/python` exists **and is executable**;
- the Higgsfield browser session directory exists (`HIGGS_VCLAW_PROFILE`, default `./.cloak-profile`);
- `<session>/.vclaw-engine-ready.json` exists, parses, and carries `"loggedIn": true`.

```json
{
  "schemaVersion": 1,
  "bootstrappedAt": "2026-09-07T12:00:00Z",
  "profileDir": "/abs/path/to/.cloak-profile",
  "cloakbrowser": "0.5.3",
  "loggedIn": true
}
```

**Only `bootstrap/import_cookies.py` writes it**, because only the cookie import proves a
sign-in. This exists because files merely existing is not readiness: the check used to
accept the Python environment and the session directory being THERE, which an empty `mkdir`
and an empty file satisfy — so a fresh machine with nothing configured reported
`seedance-direct: available` on `activeTransport: in-tree-engine`, and a `--confirm-spend`
render would have launched a broken engine instead of refusing. `vclaw video providers`
names the missing piece in `setupHint`.

So: readiness file present and signed in ⇒ free by default; anything else ⇒ safe paid
fallback (no footgun). Ask for the paid API explicitly with
`VCLAW_SEEDANCE_DIRECT_NATIVE=1`.

## Contract

| Action | stdin | stdout (required) |
|---|---|---|
| **submit** | raw `VideoExecutionPayload` (no `action`); all `tasks[]` in one call | `{ externalJobId }` (non-empty). Submits-and-returns; does not block on render. |
| **poll** | `{ action:'poll', externalJobId, outputDir, workspaceRoot, … }` | `{ status:'pending'\|'completed'\|'failed', outputs:[{ id, kind:'video', path, sceneIndex }] }`. Downloads completed scenes to `outputDir/scene-<i>.mp4`. |
| **cancel** | `{ action:'cancel', externalJobId, … }` | `{ status:'unsupported', … }` (free queue has no cancel; opens no browser). |

Job-state lives at `<outputDir>/.vclaw-jobs/<externalJobId>.json` (per-scene Higgsfield
`job_set_id`s) so `poll` resolves a `submit` across invocations. `externalJobId` is minted
as `seedance-<unix-ms>`.

## FREE-SAFETY

Only the unlimited free path is ever used (`use_unlim:true`, `use_free_gens:false`). `submit`
reads the Higgsfield wallet before and after each batch and **hard-aborts** (non-zero exit) if
`credits_balance` drops. A paid spend cannot occur.

## Phase-2 — image-to-video (start-frame upload)

When a task carries `referencePaths`, the engine uploads the **first usable visual
reference** as the i2v start frame (`medias:[{role:'image', …}]`) and routes the render to
the proven free i2v model **`seedance_unlimited`**; pure text-to-video (no usable
reference) stays on `seedance_mini_unlimited` with `medias:[]`. A video reference
contributes its **last frame** (relay-style chaining). Any reference failure degrades to
text-to-video with a non-fatal `issues[]` entry — a reference never risks spend or blocks.

Requirements learned in production (Last Call, 2026-07):

- **`referencePaths`: absolute preferred, relative now resolved.** The engine retries a
  relative path against `workspaceRoot/projects/<slug>` then `workspaceRoot` (how videoclaw
  asset manifests store paths); only a path that resolves nowhere degrades the scene to
  text-to-video (`referenceApplied:false` in job-state). Absolute paths remain the most
  explicit choice.
- **720p max — clamped automatically.** The free endpoint accepts only `480p`/`720p`; any
  other profile resolution (e.g. 1080p) is clamped to 720p at submit instead of failing
  every submit with `HTTP 422 … "Input should be '480p' or '720p'"`.
- **Diagnose from job-state first**: `<outputDir>/.vclaw-jobs/<jobId>.json` records
  per-scene `status`, `model`, `referenceApplied`, and the full submit `error`.
- **`HTTP 404 "Media input not found"` on submit** is usually the upload-finalize race (or
  an NSFW reject of the reference upstream) — a plain retry resolves the transient case.
- **Stealth-session health**: a `Page.goto` timeout on higgsfield.ai means stale auth or an
  outdated browser — re-seed cookies (`bootstrap/import_cookies.py`) and
  `pip install --upgrade cloakbrowser` in the engine venv, then retry. CloakBrowser
  0.5+ also requires `cloakbrowser login` (or `CLOAKBROWSER_LICENSE_KEY`) to use
  the newest browser build; keyless installs remain on their legacy free binary.

Still true in Phase-2: voice media (cloned-voice `@Video` mentions) is not
auto-provisioned; no NSFW retry (the caller decides); `quality` is ignored (single Mini
tier); free-lane native audio may **invent dialogue** on speech-suggesting prompts — for
scripted speech, put quoted ENGLISH lines in the scene text (see
[`docs/CLI_REFERENCE.md`](https://github.com/davendra/videoclaw-v3/blob/main/docs/CLI_REFERENCE.md) → standing render rules for
the full recipe).

> **Tests are pinned away from this engine.** `npm run test:node` sets
> `VCLAW_SEEDANCE_DIRECT_NATIVE=1` so no test can resolve `seedance-direct` to the
> bootstrapped engine and launch a real stealth-browser submit mid-suite (a
> bootstrapped engine counts as an execution override and would otherwise make the
> route genuinely available to any test that reaches `execute`).

## Verify (no spend, no browser)

```sh
HIGGS_VCLAW_TEST_STUB=1 ./.venv/bin/python test_adapter.py     # -> ALL TESTS PASS (12 tests, incl. i2v upload)
```
To prove the videoclaw→engine wiring end-to-end without a render, drive a CANCEL through the
compiled runtime (opens no browser, spends nothing):

```sh
cd ../.. && npm run build && node -e '
import("./dist/video/execution-runtime.js").then(async (m) => {
  const r = await m.cancelExecutionPayload(
    { projectSlug:"wire", routeId:"seedance-direct", externalJobId:"x", outputDir:"/tmp", workspaceRoot:"/tmp" },
    { env: process.env });
  console.log(r.status); // -> "unsupported"  (v3 resolved + spawned + parsed this engine)
});'
```

A live end-to-end free render (slow; single free slot) uses the real browser + profile.

## Optional: extra job-set params (`HIGGS_VCLAW_EXTRA_PARAMS`)

Seedance 2.5 accepts job-set fields this adapter does not model itself. Set a
JSON object to pass them through:

```sh
HIGGS_VCLAW_EXTRA_PARAMS='{"multi_shots":true,"multi_shot_mode":"custom","multi_prompt":["shot 1 …","shot 2 …"]}'
```

The shape is **not guessed** — it mirrors a captured real UI submit (the same
source `higgs_engine.py` copies from), where `seedance_2_5` renders with:

```json
{"genre":"auto","multi_shots":false,"multi_shot_mode":"custom","multi_prompt":[],
 "speedramp":"auto","reference_elements":[],"prompt_language":"en",
 "extension_mode":null,"medias":[]}
```

Notes:

- **Unset adds nothing.** 2.5 renders fine without any of these, so the default
  submit is byte-identical. This exists to exercise the fields without another
  adapter change, not because they are required.
- `model`, `prompt` and `medias` are ignored if present — they are the identity
  of the submit and the adapter owns them.
- Malformed JSON **aborts at import** rather than being ignored: a silently
  dropped override would submit a different job than asked for.
- Producing `multi_prompt` arrays from a storyboard is a videoclaw-side concern
  and is not wired yet; this is the transport half only.
