Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 51 additions & 47 deletions src/lib/components/NodeUI.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -93,53 +93,57 @@
<span class="validation-badge warning-badge">!</span>
{/if}
</div>
<div class="node-controls">
{#each definition.inputs as input (input.id)}
<div class="control-group">
<label for={input.id}>{input.label}</label>

{#if input.type === 'number'}
{@const currentValue = data[input.id] ?? input.default}
<input
id={input.id}
type="range"
min={input.min ?? 0}
max={input.max ?? 1}
step={input.step ?? 0.01}
value={currentValue}
oninput={(e) =>
handleChange(input.id, parseFloat((e.target as HTMLInputElement).value))}
class="nodrag nopan nowheel"
/>
<span class="value-display">
{currentValue.toFixed?.(2) ?? currentValue}
</span>
{:else if input.type === 'select'}
{@const currentValue = data[input.id] ?? input.default}
<select
disabled
id={input.id}
value={Number(currentValue)}
onchange={(e) => handleChange(input.id, (e.target as HTMLSelectElement).value)}
class="nodrag nopan nowheel"
>
{#each input.options ?? [] as option (option.value)}
<option value={Number(option.value)}>{option.label}</option>
{/each}
</select>
{:else if input.type === 'boolean'}
{@const currentValue = data[input.id] ?? input.default}
<input
id={input.id}
type="checkbox"
checked={currentValue}
onchange={(e) => handleChange(input.id, (e.target as HTMLInputElement).checked)}
class="nodrag nopan nowheel"
/>
{/if}
</div>
{/each}
</div>
{#if definition.id === 'cam'}
<div class="node-controls" style="height: 30px;"></div>
{:else}
<div class="node-controls">
{#each definition.inputs as input (input.id)}
<div class="control-group">
<label for={input.id}>{input.label}</label>

{#if input.type === 'number'}
{@const currentValue = data[input.id] ?? input.default}
<input
id={input.id}
type="range"
min={input.min ?? 0}
max={input.max ?? 1}
step={input.step ?? 0.01}
value={currentValue}
oninput={(e) =>
handleChange(input.id, parseFloat((e.target as HTMLInputElement).value))}
class="nodrag nopan nowheel"
/>
<span class="value-display">
{currentValue.toFixed?.(2) ?? currentValue}
</span>
{:else if input.type === 'select'}
{@const currentValue = data[input.id] ?? input.default}
<select
disabled
id={input.id}
value={Number(currentValue)}
onchange={(e) => handleChange(input.id, (e.target as HTMLSelectElement).value)}
class="nodrag nopan nowheel"
>
{#each input.options ?? [] as option (option.value)}
<option value={Number(option.value)}>{option.label}</option>
{/each}
</select>
{:else if input.type === 'boolean'}
{@const currentValue = data[input.id] ?? input.default}
<input
id={input.id}
type="checkbox"
checked={currentValue}
onchange={(e) => handleChange(input.id, (e.target as HTMLInputElement).checked)}
class="nodrag nopan nowheel"
/>
{/if}
</div>
{/each}
</div>
{/if}

{#if issues.length > 0}
<ul class="validation-messages">
Expand Down
45 changes: 42 additions & 3 deletions src/lib/engine/HydraEngine.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validateAndBuildCamNode } from '../nodes/definitions/cam.js';
import type { IREdge, IRNode } from '../types.js';

export type IssueSeverity = 'error' | 'warning';
Expand Down Expand Up @@ -118,7 +119,7 @@ export class HydraEngine {
regl: this.regl as any,
width: canvas.width,
height: canvas.height,
numOutputs: 4,
numOutputs: 1,
numSources: 4
});

Expand Down Expand Up @@ -210,6 +211,16 @@ export class HydraEngine {
return result;
}

// Handle camera node as special case
if (node.type === 'cam') {
const result = validateAndBuildCamNode(node, nodes, edges, nodeId, memo, {
hydra: this.hydra!,
generators: this.generators!,
executeGraph: this.executeGraph.bind(this)
});
return result ?? { ok: false, issues: [] };
}

// Validate transform exists
const tType = this.typeByName.get(node.type);
if (!tType) {
Expand Down Expand Up @@ -328,6 +339,10 @@ export class HydraEngine {

const outputNodes = nodes.filter((node) => node.type === 'out');
for (const outputNode of outputNodes) {
// Skip validation for inactive nodes
if (outputNode.state === 'inactive') {
continue;
}
const outputIndex = Number(outputNode.data?.outputIndex ?? 0);

// Validate output index range
Expand Down Expand Up @@ -365,9 +380,21 @@ export class HydraEngine {
}

const inputEdge = inEdges[0];
// Skip validation if source node is inactive
const sourceNode = nodes.find((n) => n.id === inputEdge.source);
if (sourceNode?.state === 'inactive') {
continue;
}

const result = this.buildChainValidated(nodes, edges, inputEdge.source, new Set(), memo);
if (!result.ok) {
issues.push(...(result as { ok: false; issues: Issue[] }).issues);
// Only add errors, not warnings for loading nodes
const errorIssues = (result as { ok: false; issues: Issue[] }).issues.filter(
(i) => i.severity === 'error'
);
if (errorIssues.length > 0) {
issues.push(...errorIssues);
}
}
}

Expand Down Expand Up @@ -397,9 +424,21 @@ export class HydraEngine {
if (inEdges.length !== 1) continue;

const inputEdge = inEdges[0];
// Skip execution if source node is inactive
const sourceNode = nodes.find((n) => n.id === inputEdge.source);
if (sourceNode?.state === 'inactive') {
continue;
}

const result = this.buildChainValidated(nodes, edges, inputEdge.source);
if (!result.ok) {
issues.push(...(result as { ok: false; issues: Issue[] }).issues);
// Only add errors, not warnings for loading nodes
const errorIssues = (result as { ok: false; issues: Issue[] }).issues.filter(
(i) => i.severity === 'error'
);
if (errorIssues.length > 0) {
issues.push(...errorIssues);
}
continue;
}

Expand Down
200 changes: 200 additions & 0 deletions src/lib/nodes/definitions/cam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { Issue, IssueKind } from '../../engine/HydraEngine.js';
import type { IREdge, IRNode, NodeDefinition } from '../../types.js';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type BuildResult = { ok: true; chain: any } | { ok: false; issues: Issue[] };

function makeIssueKey(kind: IssueKind, parts: Array<string | number | undefined>): string {
return [kind, ...parts.map((p) => (p ?? '').toString())].join(':');
}

/**
* Request camera permissions with less restrictive constraints to avoid OverconstrainedError
* Uses 'ideal' instead of 'exact' for deviceId to be more flexible
*/
export async function requestCameraPermission(deviceId: number): Promise<void> {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Camera API not available');
}

// First, enumerate devices to get available cameras
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((device) => device.kind === 'videoinput');

// Build constraints with 'ideal' instead of 'exact' to avoid OverconstrainedError
const constraints: MediaStreamConstraints = {
audio: false,
video: cameras[deviceId]
? {
deviceId: {
ideal: cameras[deviceId].deviceId
},
width: { ideal: 1280 / 2 },
height: { ideal: 720 / 2 }
}
: {
width: { ideal: 1280 / 2 },
height: { ideal: 720 / 2 }
}
};

// Request permission with less restrictive constraints
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Stop the stream immediately - we just needed permission
stream.getTracks().forEach((track) => track.stop());
}

interface CamBuildContext {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hydra: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
generators: any;
executeGraph: (nodes: IRNode[], edges: IREdge[]) => void;
}

/**
* Builds a camera node chain, handling camera initialization and state management
*/
export function validateAndBuildCamNode(
node: IRNode,
nodes: IRNode[],
edges: IREdge[],
nodeId: string,
memo: Map<string, BuildResult>,
ctx: CamBuildContext
): BuildResult | null {
// Check node state - skip if inactive
if (node.state === 'inactive') {
const result: BuildResult = {
ok: false,
issues: [
{
key: makeIssueKey('RUNTIME_EXECUTION_ERROR', [node.id]),
kind: 'RUNTIME_EXECUTION_ERROR',
severity: 'warning',
message: 'Camera node is inactive',
nodeId: node.id
}
]
};
memo.set(nodeId, result);
return result;
}

// Initialize node state if not set
if (!node.state) {
node.state = 'inactive';
}

try {
//TODO: this is basically the build of the node definition
const sourceIndex = 0;
const source = ctx.hydra.sources[sourceIndex];
const cameraIndex = Number(node.data?.source_camera ?? 0);

if (source && source.src) {
// Checks that src is ready and video is ready (source.src)
node.state = 'active';
const chain = ctx.generators.src(source);
const result = { ok: true, chain } as BuildResult;
memo.set(nodeId, result);

return result;
} else {
// Trigger camera initialization if not already loading
if (node.state === 'inactive' && source && typeof source.initCam === 'function') {
// Request camera permissions first with less restrictive constraints
// This helps avoid OverconstrainedError by using 'ideal' instead of 'exact'
requestCameraPermission(cameraIndex)
.then(() => {
source.initCam(cameraIndex);
//TODO: Ideally node build should be called here;

// Poll for source.src to be defined, then execute graph
const maxAttempts = 30;
const pollInterval = 200; // Check every 200ms
let attempts = 0;

const checkInterval = setInterval(() => {
attempts++;

if (source.src) {
clearInterval(checkInterval);
ctx.executeGraph(nodes, edges);
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.warn('Timeout waiting for camera source to be ready');
node.state = 'inactive';
}
}, pollInterval);
})
.catch((err) => {
console.error('Camera permission denied or error:', err);
node.state = 'inactive';
});
node.state = 'loading';
}

// Still loading
const result: BuildResult = {
ok: false,
issues: [
{
key: makeIssueKey('RUNTIME_EXECUTION_ERROR', [node.id]),
kind: 'RUNTIME_EXECUTION_ERROR',
severity: 'warning',
message: 'Camera is loading...',
nodeId: node.id
}
]
};
memo.set(nodeId, result);
return result;
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error building cam node';
const result: BuildResult = {
ok: false,
issues: [
{
key: makeIssueKey('RUNTIME_EXECUTION_ERROR', [node.id]),
kind: 'RUNTIME_EXECUTION_ERROR',
severity: 'error',
message: `Error building cam: ${message}`,
nodeId: node.id
}
]
};
memo.set(nodeId, result);
return result;
}
}

export const camDefinition: NodeDefinition = {
id: 'cam',
label: 'Camera',
category: 'source',
state: 'inactive',
inputs: [
{
id: 'source_camera',
label: 'Source Camera',
type: 'select',
default: 0,
options: [{ value: 0, label: 'cam 0' }]
//TODO: this should be a list of all browsers availables cameras L92. Hydra Engine
}
],
outputs: [],
build: () => {
//TODO: Move build definition here, currently in Hydra Engine
}
};

/**
* we need:
* init -> initialize node with 'inactive', initialize camera
* update -> check weather camera and source are correctly initialized.
* build -> creates the node once camera and source as correctly initialize
*
*/
Loading