feat(ext): add webcamCapture.v2 example exercising defineWidget+mount (W6.P3.D)

Strangler-pattern port of the WEBCAM custom widget type from the v1 webcamCapture extension to the v2 mount-lifecycle seam (Axiom A12 / D-widget-converge §Decision).

- Registers WEBCAM via defineWidget({type:'WEBCAM',mount}). The mount body constructs the <video> + container, captures them via closure (no widget.element accessor exposed per A12), and returns a cleanup that stops MediaStream tracks on widget destruction. - Uses ctx.onAfterRemount to re-attach the cached container when the host is swapped (graph ↔ app mode swap, subgraph promotion) per D-widget-converge §Clarification 1 — mount body is NOT re-invoked. - Companion defineNode stays a placeholder: GAP-2 (no type-construction addWidget('button',…)) and GAP-11 (no async setSerializedValue path; v2's WidgetBeforeSerializeEvent doesn't yet promise async resolution) block the full node-side port. v1 webcamCapture.ts remains authoritative until those gaps close.

knip.config.ts: add the new file to ignore list (matches existing v2 strangler entries; not wired into bootstrap).

Phase A gates: lint 0 errors / 3 pre-existing warnings; format:check clean; knip 6 pre-existing tag hints + 1 pre-existing config hint / 0 new failures.
This commit is contained in:
Connor Byrne
2026-05-18 15:10:40 -07:00
parent 524830023d
commit f182d1ff96
2 changed files with 113 additions and 1 deletions

View File

@@ -88,7 +88,9 @@ const config: KnipConfig = {
// the v1→v2 cut-over). Tracked by I-EXT (#12144).
'src/extensions/core/noteNode.v2.ts',
'src/extensions/core/rerouteNode.v2.ts',
'src/extensions/core/slotDefaults.v2.ts'
'src/extensions/core/slotDefaults.v2.ts',
// W6.P3.D — defineWidget+mount showcase port (D-widget-converge / A12).
'src/extensions/core/webcamCapture.v2.ts'
],
vite: {
config: ['vite?(.*).config.mts']

View File

@@ -0,0 +1,110 @@
/**
* WebcamCapture — rewritten with the v2 extension API.
*
* v1: registers the `WEBCAM` custom widget type via `getCustomWidgets()`
* returning `node.addDOMWidget(name, 'WEBCAM', container)`, then a
* separate `nodeCreated` reaches into `node.widgets` to wire the
* capture button and `serializeValue` override.
*
* v2: registers the `WEBCAM` widget type via `defineWidget({ type, mount })`
* per **Axiom A12** — the mount-lifecycle hook is the sole DOM seam.
* The mount body captures `host` (and the constructed `<video>`) via
* closure; there is no `widget.element` accessor on `WidgetHandle`.
* Cleanup stops the camera stream when the widget is destroyed
* (D-widget-converge §Clarification 1: cleanup = destruction-only).
*
* The `nodeCreated` half of the v1 extension (wiring the capture button +
* serializeValue override) surfaces several gaps already tracked under
* I-COORD.1 — see GAP comments inline.
*/
import { defineNode, defineWidget, type NodeHandle } from '@/extension-api'
// ── defineWidget — Axiom A12 mount-lifecycle seam ───────────────────────────
export default defineWidget({
name: 'Comfy.WebcamCapture.V2.Widget',
type: 'WEBCAM',
mount(host, ctx) {
const container = document.createElement('div')
container.style.background = 'rgba(0,0,0,0.25)'
container.style.textAlign = 'center'
const video = document.createElement('video')
video.style.height = video.style.width = '100%'
let stream: MediaStream | null = null
const loadVideo = async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
})
container.replaceChildren(video)
video.srcObject = stream
await video.play()
} catch (error) {
const label = document.createElement('div')
label.style.color = 'red'
label.style.overflow = 'auto'
label.style.maxHeight = '100%'
label.style.whiteSpace = 'pre-wrap'
const message = error instanceof Error ? error.message : String(error)
label.textContent = window.isSecureContext
? `Unable to load webcam, please ensure access is granted:\n${message}`
: `Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n${message}`
container.replaceChildren(label)
}
}
host.appendChild(container)
void loadVideo()
// Re-bind the video element to the new host on remount (graph ↔ app
// mode swap, subgraph promotion). Mount body is NOT re-invoked per
// D-widget-converge §Clarification 1.
ctx.onAfterRemount((newHost) => {
newHost.appendChild(container)
})
// Destruction-only cleanup: stop the camera stream + release tracks.
return () => {
stream?.getTracks().forEach((t) => t.stop())
stream = null
video.srcObject = null
container.remove()
}
}
})
// ── Companion defineNode — capture button + serializeValue wiring ──────────
//
// The `WebcamCapture` node-side logic still has open v2 surface gaps:
// GAP-2 (I-COORD.1): no type-construction `addWidget('button', …)` on
// NodeHandle — the v1 path adds a button programmatically inside
// `nodeCreated` to drive `capture()`.
// GAP-11 (new): no `widget.serializeValue = async () => …` setter
// on WidgetHandle. The v2 path is `widget.on('beforeSerialize',
// e => e.setSerializedValue(…))`, but the v1 override is *async*
// (uploads to /upload/image and awaits the response); the
// `beforeSerialize` payload shape (D5) does not yet promise async
// resolution. Tracked separately — do not unblock here.
// Until those land, the node-side companion stays a v1 extension. The
// `defineNode` below is a placeholder that registers the type filter so
// downstream tooling can correlate the v2 widget registration with the
// node that consumes it.
defineNode({
name: 'Comfy.WebcamCapture.V2.Node',
nodeTypes: ['WebcamCapture'],
nodeCreated(_node: NodeHandle) {
// Wiring deferred — see GAP-2 / GAP-11 above. The v1 extension's
// nodeCreated body remains the authoritative implementation until
// those gaps close.
}
})