mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix: composition issues in runtime code + producer parity
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
docs/
|
||||
DOCS_GUIDELINES.md
|
||||
@@ -276,11 +276,11 @@ export function lintHyperframeHtml(
|
||||
continue;
|
||||
}
|
||||
pushFinding({
|
||||
code: "suspicious_global_gsap_selector",
|
||||
code: "unscoped_gsap_selector",
|
||||
severity: "warning",
|
||||
message: `Timeline "${localTimelineCompId}" uses a global selector "${window.targetSelector}" that may escape composition scope.`,
|
||||
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
||||
selector: window.targetSelector,
|
||||
fixHint: `Scope the selector like \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
|
||||
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
|
||||
snippet: truncateSnippet(window.raw),
|
||||
});
|
||||
}
|
||||
@@ -346,6 +346,77 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// #3.5: Self-closing <audio .../> or <video .../> — CRITICAL
|
||||
// In HTML5, <audio> and <video> are NOT void elements. The browser silently
|
||||
// ignores the "/>", leaving the tag open. All subsequent sibling elements
|
||||
// become invisible fallback content inside the media tag, making entire
|
||||
// compositions disappear. This is the #1 cause of "black preview" bugs.
|
||||
{
|
||||
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
|
||||
let scMatch: RegExpExecArray | null;
|
||||
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
|
||||
const tagName = scMatch[1] || "audio";
|
||||
const elementId = readAttr(scMatch[0], "id") || undefined;
|
||||
pushFinding({
|
||||
code: "self_closing_media_tag",
|
||||
severity: "error",
|
||||
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
|
||||
elementId,
|
||||
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
|
||||
snippet: truncateSnippet(scMatch[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #3.6: Placeholder/fake media URLs — CRITICAL
|
||||
// Placeholder URLs (placehold.co, placeholder.com, example.com) will 404 at render time.
|
||||
{
|
||||
const PLACEHOLDER_DOMAINS =
|
||||
/\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
|
||||
for (const tag of tags) {
|
||||
if (!isMediaTag(tag.name)) continue;
|
||||
const src = readAttr(tag.raw, "src");
|
||||
if (!src) continue;
|
||||
if (PLACEHOLDER_DOMAINS.test(src)) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
pushFinding({
|
||||
code: "placeholder_media_url",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
|
||||
elementId,
|
||||
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #3.7: Fabricated inline base64 media — CRITICAL
|
||||
// Inline base64 audio/video data is almost always fabricated garbage that
|
||||
// won't play. Real audio files are 100KB+ when base64-encoded.
|
||||
{
|
||||
const base64MediaRe =
|
||||
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{100,}))["']/gi;
|
||||
let b64Match: RegExpExecArray | null;
|
||||
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
||||
// Check if it's suspiciously repetitive (fake data has long runs of repeated chars)
|
||||
const sample = (b64Match[2] || "").slice(0, 200);
|
||||
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
||||
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
|
||||
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
|
||||
// Any embedded base64 audio is suspicious — real audio should be a file
|
||||
pushFinding({
|
||||
code: "fabricated_inline_media",
|
||||
severity: isSuspicious ? "error" : "warning",
|
||||
message: isSuspicious
|
||||
? `Fabricated base64 media detected (${(dataSize / 1024).toFixed(0)} KB). This is almost certainly fake data that won't play.`
|
||||
: `Embedded base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB). Consider using a file URL instead.`,
|
||||
fixHint: "Remove the data: URI and use a real media file URL instead.",
|
||||
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
||||
@@ -718,3 +789,96 @@ function truncateSnippet(value: string, maxLength = 220): string | undefined {
|
||||
}
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
// ── Async media URL accessibility checker ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract all remote media URLs from HTML source.
|
||||
*/
|
||||
function extractMediaUrls(
|
||||
html: string,
|
||||
): Array<{ url: string; tagName: string; elementId?: string; snippet: string }> {
|
||||
const results: Array<{ url: string; tagName: string; elementId?: string; snippet: string }> = [];
|
||||
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(html)) !== null) {
|
||||
const tagName = (match[1] ?? "").toLowerCase();
|
||||
const raw = match[0];
|
||||
const src = readAttr(raw, "src");
|
||||
if (!src) continue;
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
results.push({
|
||||
url: src,
|
||||
tagName,
|
||||
elementId: readAttr(raw, "id") || undefined,
|
||||
snippet: raw.length > 120 ? raw.slice(0, 117) + "..." : raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async lint pass: HEAD-checks every remote media URL in the HTML.
|
||||
* Returns findings for URLs that are unreachable (non-2xx status or network error).
|
||||
*
|
||||
* Call this after `lintHyperframeHtml()` and merge the findings.
|
||||
*
|
||||
* @param timeoutMs - per-request timeout (default 8000ms)
|
||||
*/
|
||||
export async function lintMediaUrls(
|
||||
html: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<HyperframeLintFinding[]> {
|
||||
const urls = extractMediaUrls(html);
|
||||
if (urls.length === 0) return [];
|
||||
|
||||
const timeout = options.timeoutMs ?? 8000;
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
// Dedupe by URL
|
||||
const seen = new Set<string>();
|
||||
const unique = urls.filter((u) => {
|
||||
if (seen.has(u.url)) return false;
|
||||
seen.add(u.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Check all URLs in parallel
|
||||
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const resp = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!resp.ok) {
|
||||
findings.push({
|
||||
code: "inaccessible_media_url",
|
||||
severity: "error",
|
||||
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
|
||||
elementId,
|
||||
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.name : "unknown";
|
||||
findings.push({
|
||||
code: "inaccessible_media_url",
|
||||
severity: "error",
|
||||
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
|
||||
elementId,
|
||||
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(checks);
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ export type {
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./types";
|
||||
export { lintHyperframeHtml } from "./hyperframeLinter";
|
||||
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter";
|
||||
|
||||
@@ -184,21 +184,20 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
|
||||
const resolveRootCompositionElement = (): HTMLElement | null => {
|
||||
const mainComp = document.getElementById("main-comp");
|
||||
if (mainComp instanceof HTMLElement && mainComp.hasAttribute("data-composition-id")) {
|
||||
return mainComp;
|
||||
}
|
||||
// 1. Explicit root marker takes priority
|
||||
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
|
||||
if (explicitRoot instanceof HTMLElement) {
|
||||
return explicitRoot;
|
||||
}
|
||||
// 3. Topmost composition element (not nested inside another)
|
||||
const compositionNodes = Array.from(
|
||||
document.querySelectorAll("[data-composition-id]"),
|
||||
) as HTMLElement[];
|
||||
if (compositionNodes.length === 0) return null;
|
||||
return (
|
||||
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
|
||||
compositionNodes[0]
|
||||
compositionNodes[0] ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
@@ -215,15 +214,18 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const sanitizeCompositionDurationAttributes = () => {
|
||||
const rootEl = resolveRootCompositionElement();
|
||||
const compositionNodes = Array.from(
|
||||
document.querySelectorAll("[data-composition-id][data-duration]"),
|
||||
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")).filter(
|
||||
(n) => n.hasAttribute("data-duration") || n.hasAttribute("data-end"),
|
||||
) as HTMLElement[];
|
||||
for (const node of compositionNodes) {
|
||||
// Preserve explicit root duration so timeline payload can distinguish
|
||||
// authored finite duration from loop-inflated timeline duration.
|
||||
if (rootEl && node === rootEl) continue;
|
||||
// Non-root compositions derive duration from timeline.
|
||||
// Strip both data-duration AND data-end so the visibility system
|
||||
// falls back to the GSAP timeline duration (parity with preview).
|
||||
node.removeAttribute("data-duration");
|
||||
node.removeAttribute("data-end");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,24 @@ export function createRuntimeStartTimeResolver(params: {
|
||||
try {
|
||||
const expression = parseStartExpression(element.getAttribute("data-start"));
|
||||
if (!expression) {
|
||||
// If this element is a loaded composition inner root (has data-composition-id
|
||||
// but no data-start), walk up to the host parent which carries the actual
|
||||
// timing. This happens when the host uses a different data-composition-id
|
||||
// than the loaded file — e.g. host="montage" but file has "scene-10".
|
||||
// Check both data-composition-src (runtime) and data-composition-id (bundled,
|
||||
// where data-composition-src is stripped after inlining).
|
||||
if (element.hasAttribute("data-composition-id")) {
|
||||
const parent = element.parentElement;
|
||||
if (
|
||||
parent &&
|
||||
(parent.hasAttribute("data-composition-src") ||
|
||||
parent.hasAttribute("data-composition-id"))
|
||||
) {
|
||||
const parentStart = resolveStartForElementInternal(parent, fallback);
|
||||
startCache.set(element, parentStart);
|
||||
return parentStart;
|
||||
}
|
||||
}
|
||||
startCache.set(element, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,60 @@ function parseNum(value: string | null | undefined): number | null {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When multiple content kinds share the same track number, split them
|
||||
* onto separate tracks so the timeline UI shows distinct rows.
|
||||
*
|
||||
* Preferred kind order (top → bottom): composition, video, image, element, audio.
|
||||
* Tracks that contain only one kind are left untouched.
|
||||
*/
|
||||
const KIND_ORDER: Record<string, number> = {
|
||||
composition: 0,
|
||||
video: 1,
|
||||
image: 2,
|
||||
element: 3,
|
||||
audio: 4,
|
||||
};
|
||||
|
||||
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
|
||||
if (clips.length === 0) return;
|
||||
|
||||
// Group clips by their raw track number and detect which tracks have mixed kinds
|
||||
const trackKinds = new Map<number, Set<string>>();
|
||||
for (const clip of clips) {
|
||||
const kinds = trackKinds.get(clip.track) ?? new Set();
|
||||
kinds.add(clip.kind);
|
||||
trackKinds.set(clip.track, kinds);
|
||||
}
|
||||
|
||||
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
|
||||
if (!hasMixedTracks) return;
|
||||
|
||||
// Build new contiguous track numbers, splitting mixed tracks by kind
|
||||
let nextTrack = 0;
|
||||
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
|
||||
|
||||
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
|
||||
for (const track of sortedTracks) {
|
||||
const kinds = trackKinds.get(track)!;
|
||||
if (kinds.size === 1) {
|
||||
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
|
||||
} else {
|
||||
// Split by kind in preferred order
|
||||
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
|
||||
for (const kind of sorted) {
|
||||
newTrackMap.set(`${track}:${kind}`, nextTrack++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const clip of clips) {
|
||||
const key = `${clip.track}:${clip.kind}`;
|
||||
const newTrack = newTrackMap.get(key);
|
||||
if (newTrack != null) clip.track = newTrack;
|
||||
}
|
||||
}
|
||||
|
||||
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
|
||||
const raw = String(rawValue ?? "").trim();
|
||||
if (!raw) return null;
|
||||
@@ -118,7 +172,10 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
inheritedStart = startResolver.resolveStartForElement(cursor, 0);
|
||||
}
|
||||
if (inheritedDuration == null) {
|
||||
inheritedDuration = parseNum(cursor.getAttribute("data-duration")) ?? null;
|
||||
inheritedDuration =
|
||||
parseNum(cursor.getAttribute("data-duration")) ??
|
||||
resolveTimelineDurationSeconds(compositionId) ??
|
||||
null;
|
||||
}
|
||||
}
|
||||
cursor = cursor.parentElement;
|
||||
@@ -243,11 +300,12 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
? "image"
|
||||
: "element";
|
||||
clips.push({
|
||||
id: (node as HTMLElement).id || `__node__index_${i}`,
|
||||
id: (node as HTMLElement).id || nodeCompositionId || `__node__index_${i}`,
|
||||
label:
|
||||
node.getAttribute("data-timeline-label") ??
|
||||
node.getAttribute("data-label") ??
|
||||
node.getAttribute("aria-label") ??
|
||||
nodeCompositionId ??
|
||||
(node as HTMLElement).id ??
|
||||
(node as HTMLElement).className?.split(" ")[0] ??
|
||||
kind,
|
||||
@@ -272,6 +330,12 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
timelinePriority: parseNum(node.getAttribute("data-timeline-priority")),
|
||||
});
|
||||
}
|
||||
// ── Track normalization ────────────────────────────────────────────────
|
||||
// When multiple content kinds (composition, audio, video, …) share the same
|
||||
// data-track-index value, split them onto separate tracks so the timeline UI
|
||||
// shows distinct rows for each kind.
|
||||
normalizeTrackAssignments(clips);
|
||||
|
||||
for (const compositionNode of compositionNodes) {
|
||||
if (compositionNode === root) continue;
|
||||
const compositionId = compositionNode.getAttribute("data-composition-id");
|
||||
|
||||
@@ -57,6 +57,22 @@ await Promise.all([
|
||||
}),
|
||||
]);
|
||||
|
||||
// Copy core runtime artifacts so the producer can find them at dist/
|
||||
import { copyFileSync, existsSync, readFileSync } from "fs";
|
||||
const coreDistDir = resolve(scriptDir, "../core/dist");
|
||||
try {
|
||||
const manifestSrc = resolve(coreDistDir, "hyperframe.manifest.json");
|
||||
if (existsSync(manifestSrc)) {
|
||||
copyFileSync(manifestSrc, "dist/hyperframe.manifest.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
|
||||
const runtimeIife = manifest?.artifacts?.iife || "hyperframe.runtime.iife.js";
|
||||
copyFileSync(resolve(coreDistDir, runtimeIife), `dist/${runtimeIife}`);
|
||||
console.log(`[Build] Copied runtime: hyperframe.manifest.json, ${runtimeIife}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Build] Warning: Could not copy runtime artifacts:", e.message);
|
||||
}
|
||||
|
||||
// Generate .d.ts declarations (esbuild doesn't emit them)
|
||||
import { execSync } from "child_process";
|
||||
execSync("tsc --emitDeclarationOnly --declaration --declarationMap", {
|
||||
|
||||
@@ -61,7 +61,13 @@ async function resolveMediaDuration(
|
||||
|
||||
if (isHttpUrl(src)) {
|
||||
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
|
||||
filePath = await downloadToTemp(src, downloadDir);
|
||||
try {
|
||||
filePath = await downloadToTemp(src, downloadDir);
|
||||
} catch {
|
||||
// Download failed (e.g. 404 placeholder URL) — skip gracefully.
|
||||
// The element will get duration 0 and be excluded from the render.
|
||||
return { duration: 0, resolvedPath: src };
|
||||
}
|
||||
} else if (!filePath.startsWith("/")) {
|
||||
filePath = join(baseDir, filePath);
|
||||
}
|
||||
@@ -574,9 +580,7 @@ function inlineSubCompositions(
|
||||
const existing = host.getAttribute("style") || "";
|
||||
const needsWidth = !existing.includes("width");
|
||||
const needsHeight = !existing.includes("height");
|
||||
const needsPosition = !existing.includes("position");
|
||||
const additions = [
|
||||
needsPosition ? "position:relative" : "",
|
||||
needsWidth ? `width:${hostW}px` : "",
|
||||
needsHeight ? `height:${hostH}px` : "",
|
||||
]
|
||||
@@ -609,6 +613,24 @@ function inlineSubCompositions(
|
||||
* Returns everything the orchestrator needs: compiled HTML, all media elements,
|
||||
* dimensions, and static duration.
|
||||
*/
|
||||
/**
|
||||
* Ensure the HTML is a full document (has <html>, <head>, <body>).
|
||||
* When index.html is a fragment (e.g. just a <div>), linkedom.parseHTML()
|
||||
* returns a document with null head/body, causing inlineSubCompositions to
|
||||
* silently discard all collected composition styles and scripts.
|
||||
*/
|
||||
function ensureFullDocument(html: string): string {
|
||||
const trimmed = html.trim();
|
||||
if (/^<!DOCTYPE\s+html/i.test(trimmed) || /^<html/i.test(trimmed)) {
|
||||
return html;
|
||||
}
|
||||
return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n${html}\n</body>\n</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile an HTML composition project into a single self-contained HTML string
|
||||
* with all media metadata resolved.
|
||||
*/
|
||||
export async function compileForRender(
|
||||
projectDir: string,
|
||||
htmlPath: string,
|
||||
@@ -628,10 +650,16 @@ export async function compileForRender(
|
||||
subCompositions,
|
||||
} = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
|
||||
|
||||
// Ensure the HTML is a full document before inlining sub-compositions.
|
||||
// When index.html is a fragment (no <html>/<head>/<body>), linkedom.parseHTML()
|
||||
// returns a document with null head/body, which causes inlineSubCompositions to
|
||||
// silently discard all collected composition styles and scripts.
|
||||
const fullHtml = ensureFullDocument(compiledHtml);
|
||||
|
||||
// Inline sub-compositions into the main HTML so the runtime takes the same
|
||||
// synchronous code path as the bundled preview (no async fetch of
|
||||
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
|
||||
const inlinedHtml = inlineSubCompositions(compiledHtml, subCompositions, projectDir);
|
||||
const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
|
||||
|
||||
const html = injectDeterministicFontFaces(
|
||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml)),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, copyFileSync, appendFileSync } from "fs";
|
||||
import {
|
||||
type EngineConfig as ProducerConfig,
|
||||
type EngineConfig,
|
||||
resolveConfig,
|
||||
extractAllVideoFrames,
|
||||
createFrameLookupTable,
|
||||
@@ -94,7 +94,7 @@ export interface RenderConfig {
|
||||
useGpu?: boolean;
|
||||
debug?: boolean;
|
||||
/** Full producer config. When provided, env vars are not read. */
|
||||
producerConfig?: ProducerConfig;
|
||||
producerConfig?: EngineConfig;
|
||||
/** Custom logger. Defaults to console-based defaultLogger. */
|
||||
logger?: ProducerLogger;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user