Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## [0.3.1] (2026-09-02)

Parity fixes aligning `Video` legacy search and analyzer output with `videodb-python`, plus fixes for Search v2 result shapes and `ask()` argument handling.

### Added

- **Legacy scene-index targeting** — `Video.legacySearch()` now accepts `sceneIndexId`, `indexId` (an alias for `sceneIndexId`, matching `videodb-python`'s `index_id` → `scene_index_id` aliasing), and `algorithm`, forwarding them to the wire. `Video.search()` forwards these when it routes to legacy. `sceneIndexId` and `algorithm` were added to the `SearchBase` type and are now emitted by the legacy search request builders (`scene_index_id`, `algorithm`)

### Changed

- **`ask()` accepts an options object** — `Video.ask()` and `Collection.ask()` now take an `AskOptions` (`topK`, `mode`, `includeSources`) as their second argument. The parameter was previously `topK: number`, so an options object was serialised into `top_k` and `mode` / `includeSources` were silently dropped. The positional form (`ask(question, 20, 'default', true)`) still works, and `AskOptions` is exported from `@/types/search`
- **`Shot` metadata widened for Search v2** — `videoLength` and `videoTitle` are now optional and `streamUrl` / `playerUrl` nullable on both `Shot` and `ShotBase`, since Search v2 omits length and title and sends explicit nulls for stream keys. `getEmbedCode()`'s error no longer suggests `generateStream()` or `autoGenerate`, neither of which helps when the server returns no player URL

### Fixed

- **`search({ indexId })` no longer throws** — the singular `indexId` was incorrectly listed as an unsupported selector, so passing it raised "Cannot mix legacy search params" instead of routing to `legacySearch()`. It is now treated as a legacy selector, mirroring `videodb-python` whose `unsupported_params` holds `index_ids` but not `index_id`
- **Legacy `SemanticSearch` payload parity** — `dynamic_score_percentage` (defaulting `null`) and `filter` (defaulting `[]`) are now always emitted, matching `videodb-python`'s `SemanticSearch` request payload; previously they were omitted when unset, which changed server-side ranking/results
- **`Understanding.getAnalyzerOutput()` preserves raw keys** — the call now skips camelCase conversion (`convert: false`), so the server's snake_case keys (e.g. `scene_id`) survive. Camelcasing renamed `scene_id` → `sceneId`, which the index endpoint does not recognize, breaking the round-trip of analyzer output back into `Video.index()` as a `source`
- **`getEmbedCode({ autoGenerate: true })` on search shots** — `generateStream()` short-circuited when `streamUrl` alone was set, so shots returned by search never fetched their `playerUrl` and `getEmbedCode()` threw despite auto-generation. The request is now skipped only when both URLs are already known, and sends `length: null` for shots that carry no video length rather than letting `JSON.stringify` coerce `NaN`
- **`videoLength` is no longer `NaN`** — `parseFloat(result.length)` produced `NaN` when Search v2 sent `length: null`. Values now pass through a finite-number coercion that yields `undefined` for null, empty, or non-numeric input
- **`streamUrl` no longer dropped from serialised shots** — `doc.streamLink ?? doc.streamUrl` fell through a real `null` onto a key the server never emits, leaving `streamUrl` as `undefined` so `JSON.stringify()` omitted it entirely. It now uses `||` and normalises absent streams to `null`, matching `videodb-python`

## [0.3.0] (2026-07-25)

Indexing v2 — a new retrieval architecture plus a generation/compute stack, ported from `videodb-python`'s `feat/add-indexing-v2`.
Expand Down
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/test/**/*.spec.ts'],
// Mirrors the `@/*` paths in tsconfig.json so specs can import from src.
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'<rootDir>/src/**/*.ts',
'!<rootDir>/src/types/**/*.ts',
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "videodb",
"version": "0.3.0",
"version": "0.3.1",
"description": "A NodeJS wrapper for VideoDB's API written in TypeScript",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -96,5 +96,8 @@
"tar": "^7.4.3",
"uuid": "^9.0.1",
"ws": "^8.19.0"
},
"browser": {
"./dist/utils/openBrowser.js": "./dist/utils/openBrowser.browser.js"
}
}
23 changes: 18 additions & 5 deletions src/core/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
RecordMeetingConfig,
CaptureSessionBase,
} from '@/interfaces/core';
import { IndexType, SearchType } from '@/types/search';
import { IndexType, SearchType, type AskOptions } from '@/types/search';
import type { FileUploadConfig, URLUploadConfig } from '@/types/collection';
import type {
CreateCaptureSessionConfig,
Expand Down Expand Up @@ -443,16 +443,29 @@ export class Collection implements ICollection {

/**
* Ask a question and get an answer generated from retrieved collection context.
* @param question - The question to answer
* @param optionsOrTopK - Options object, or `topK` for the legacy positional form
* @param mode - Retrieval/answer mode, positional form only (default `"default"`)
* @param includeSources - Whether to include source shots, positional form only (default false)
*/
public ask = async (
question: string,
topK: number = 15,
mode: string = 'default',
includeSources: boolean = false
optionsOrTopK: AskOptions | number = {},
mode?: string,
includeSources?: boolean
): Promise<AskResponse> => {
const opts: AskOptions =
typeof optionsOrTopK === 'number'
? { topK: optionsOrTopK, mode, includeSources }
: optionsOrTopK;
const res = await this.#vhttp.post<Record<string, unknown>, object>(
[collection, this.id, ask],
{ question, top_k: topK, mode, include_sources: includeSources }
{
question,
top_k: opts.topK ?? 15,
mode: opts.mode ?? 'default',
include_sources: opts.includeSources ?? false,
}
);
return new AskResponse(this.#vhttp, res.data as AskResponseData);
};
Expand Down
35 changes: 29 additions & 6 deletions src/core/search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ class SceneSearch implements Search<SceneVideoSearch, SceneCollectionSearch> {
if (data.filter !== undefined) {
reqData.filter = data.filter;
}
if (data.sceneIndexId !== undefined) {
reqData.scene_index_id = data.sceneIndexId;
}
if (data.algorithm !== undefined) {
reqData.algorithm = data.algorithm;
}
return reqData;
};

Expand Down Expand Up @@ -83,16 +89,21 @@ class SemanticSearch
data.scoreThreshold ?? SemanticSearchDefaultValues.scoreThreshold,
result_threshold:
data.resultThreshold ?? SemanticSearchDefaultValues.resultThreshold,
// Always emitted, matching videodb-python's SemanticSearch payload where
// `dynamic_score_percentage` is always present (null when unset) and
// legacy_search passes `filter=[]` by default.
dynamic_score_percentage: data.dynamicScorePercentage ?? null,
filter: data.filter ?? [],
};
if (data.dynamicScorePercentage !== undefined) {
reqData.dynamic_score_percentage = data.dynamicScorePercentage;
}
if (data.filter !== undefined) {
reqData.filter = data.filter;
}
if (data.sortDocsOn !== undefined) {
reqData.sort_docs_on = data.sortDocsOn;
}
if (data.sceneIndexId !== undefined) {
reqData.scene_index_id = data.sceneIndexId;
}
if (data.algorithm !== undefined) {
reqData.algorithm = data.algorithm;
}
return reqData;
};

Expand Down Expand Up @@ -141,6 +152,12 @@ class KeywordSearch
if (data.filter !== undefined) {
reqData.filter = data.filter;
}
if (data.sceneIndexId !== undefined) {
reqData.scene_index_id = data.sceneIndexId;
}
if (data.algorithm !== undefined) {
reqData.algorithm = data.algorithm;
}
return reqData;
};

Expand Down Expand Up @@ -189,6 +206,12 @@ class LLMSearch
if (data.filter !== undefined) {
reqData.filter = data.filter;
}
if (data.sceneIndexId !== undefined) {
reqData.scene_index_id = data.sceneIndexId;
}
if (data.algorithm !== undefined) {
reqData.algorithm = data.algorithm;
}
return reqData;
};

Expand Down
18 changes: 14 additions & 4 deletions src/core/search/searchResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import { HttpClient } from '@/utils/httpClient';
/** Camelcase version of SearchResponse for internal use */
type SearchResponseCamel = SnakeKeysToCamelCase<SearchResponse>;

/** Search V2 sends `length: null`; never turn a missing value into NaN. */
const toFiniteNumber = (value: unknown): number | undefined => {
if (value === null || value === undefined || value === '') return undefined;
const num = Number(value);
return Number.isFinite(num) ? num : undefined;
};

export class SearchResult implements Iterable<Shot> {
#vhttp: HttpClient;
#searchResponse: SearchResponseCamel;
Expand All @@ -34,13 +41,16 @@ export class SearchResult implements Iterable<Shot> {
text: doc.text,
searchScore: doc.score,
videoId: result.videoId,
videoTitle: result.title,
videoLength: parseFloat(result.length),
videoTitle: result.title ?? undefined,
videoLength: toFiniteNumber(result.length),
sceneIndexId: doc.sceneIndexId,
sceneIndexName: doc.sceneIndexName,
metadata: doc.metadata,
streamUrl: doc.streamLink ?? doc.streamUrl,
playerUrl: doc.playerUrl,
// `||`, not `??` — mirrors videodb-python's
// `doc.get("stream_link") or doc.get("stream_url")`, and normalises
// the absent-key `undefined` to `null` so serialised shots match.
streamUrl: doc.streamLink || doc.streamUrl || null,
playerUrl: doc.playerUrl ?? null,
})
);
}
Expand Down
22 changes: 12 additions & 10 deletions src/core/shot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ const { video, stream } = ApiPath;
*/
export class Shot implements IShot {
public readonly videoId: string;
public readonly videoLength: number;
public readonly videoTitle: string;
public readonly videoLength?: number;
public readonly videoTitle?: string;
public readonly start: number;
public readonly end: number;
public readonly text?: string;
public readonly searchScore?: number;
public readonly sceneIndexId?: string;
public readonly sceneIndexName?: string;
public readonly metadata?: Record<string, unknown>;
public streamUrl?: string;
public playerUrl?: string;
public streamUrl?: string | null;
public playerUrl?: string | null;
#vhttp: HttpClient;

constructor(http: HttpClient, data: ShotBase) {
Expand All @@ -43,16 +43,20 @@ export class Shot implements IShot {
}

/**
* Get the streaming URL for the shot
* Get the streaming URL for the shot.
* Shots produced by search arrive with a `streamUrl` but no `playerUrl`, so
* the request is only skipped when both are already known.
* @returns A streaming URL for the shot
*/
generateStream = async () => {
if (this.streamUrl) {
if (this.streamUrl && this.playerUrl) {
return this.streamUrl;
}

const body = {
length: this.videoLength,
// Search V2 shots have no length; send null like videodb-python rather
// than letting JSON.stringify turn NaN into null implicitly.
length: this.videoLength ?? null,
timeline: [[this.start, this.end]] as Timeline,
};

Expand Down Expand Up @@ -96,9 +100,7 @@ export class Shot implements IShot {
await this.generateStream();
}
if (!this.playerUrl) {
throw new VideodbError(
'player_url not available. Call generateStream() first or set autoGenerate=true.'
);
throw new VideodbError('player_url not available for this shot.');
}
return buildIframeEmbedCode(
this.playerUrl,
Expand Down
32 changes: 22 additions & 10 deletions src/core/understanding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,17 +381,29 @@ export class Understanding {
throw new Error(`Analyzer not found: ${nameOrId}`);
};

/** Return output for an analyzer by name or id. */
/**
* Return output for an analyzer by name or id.
*
* Returned with the server's raw snake_case keys (e.g. `scene_id`) — this
* output is meant to be round-tripped back into `Video.index` as a `source`,
* and the server keys off `scene_id`. Camelcasing it (the default response
* conversion) would rename `scene_id` to `sceneId`, which the index endpoint
* no longer recognizes, so the round-trip must skip conversion.
*/
public getAnalyzerOutput = async (nameOrId: string): Promise<unknown> => {
const res = await this.#vhttp.get<unknown>([
video,
this.videoId,
understand,
this.id ?? '',
'analyzers',
nameOrId,
'output',
]);
const res = await this.#vhttp.get<unknown>(
[
video,
this.videoId,
understand,
this.id ?? '',
'analyzers',
nameOrId,
'output',
],
undefined,
{ convert: false }
);
return res.data;
};

Expand Down
Loading
Loading