AstralView/packages/sdk/src/core/goview/convert.ts
2026-01-29 19:13:40 +08:00

984 lines
29 KiB
TypeScript

import {
ASTRALVIEW_SCHEMA_VERSION,
createEmptyScreen,
type Screen,
type WidgetNode,
} from '../schema';
import { convertGoViewImageOptionToNodeProps, type GoViewImageOption } from '../widgets/image';
import { convertGoViewIframeOptionToNodeProps, type GoViewIframeOption } from '../widgets/iframe';
import { convertGoViewVideoOptionToNodeProps, type GoViewVideoOption } from '../widgets/video';
import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text';
import { pickUrlLike } from '../widgets/urlLike';
export interface GoViewComponentLike {
id?: string;
// some exports wrap the actual component under a nested field
component?: GoViewComponentLike;
// component identity
key?: string; // e.g. "TextCommon" (sometimes)
componentKey?: string;
componentName?: string;
name?: string;
title?: string;
chartConfig?: {
key?: string;
chartKey?: string;
type?: string;
name?: string;
title?: string;
chartName?: string;
option?: unknown;
data?: unknown;
};
// geometry
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
// state
status?: { lock?: boolean; hide?: boolean };
// widget-specific config
option?: unknown;
}
export interface GoViewEditCanvasConfigLike {
projectName?: string;
width?: number;
height?: number;
background?: string;
}
export interface GoViewStorageLike {
editCanvasConfig?: GoViewEditCanvasConfigLike;
componentList?: GoViewComponentLike[];
}
export interface GoViewProjectLike {
width?: number;
height?: number;
canvas?: { width?: number; height?: number };
componentList?: GoViewComponentLike[];
// persisted store shape (some variants)
editCanvasConfig?: GoViewEditCanvasConfigLike;
}
function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike {
// Prefer nested component shapes but keep outer geometry/id as fallback.
// Some exports wrap components multiple times like:
// { id, attr, component: { component: { chartConfig, option } } }
// We unwrap iteratively to avoid recursion pitfalls.
//
// Important: do NOT let `undefined` values from inner layers overwrite outer values.
// Some goView exports include wrapper objects where `id/attr` only exist on the outer layer.
let out: GoViewComponentLike = c;
let depth = 0;
while (out.component && depth < 6) {
const outer = out;
const inner = out.component;
out = {
// Outer (wrapper) often contains the stable identity/geometry.
id: outer.id ?? inner.id,
attr: outer.attr ?? inner.attr,
status: outer.status ?? inner.status,
// Inner tends to carry the real widget identity/config.
key: inner.key ?? outer.key,
componentKey: inner.componentKey ?? outer.componentKey,
chartConfig: inner.chartConfig ?? outer.chartConfig,
option: inner.option ?? outer.option,
// keep unwrapping if there are more layers
component: inner.component,
};
depth++;
}
return out;
}
function keyOf(cIn: GoViewComponentLike): string {
const c = unwrapComponent(cIn);
return (
c.chartConfig?.key ??
c.chartConfig?.chartKey ??
c.chartConfig?.type ??
c.chartConfig?.name ??
c.chartConfig?.title ??
c.chartConfig?.chartName ??
c.componentKey ??
c.key ??
c.componentName ??
c.name ??
c.title ??
''
).toLowerCase();
}
function isTextCommon(c: GoViewComponentLike): boolean {
const k = keyOf(c);
if (k === 'textcommon') return true;
return k.includes('text');
}
function isImage(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Image", "image", sometimes with suffixes.
return k === 'image' || k.includes('image') || k.includes('picture');
}
function isIframe(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Iframe", "IframeCommon", etc.
if (k === 'iframe' || k.includes('iframe')) return true;
// Other names seen in low-code editors for embedded web content.
// Keep this fairly conservative; video/image are handled separately.
return (
k.includes('embed') ||
k === 'frame' ||
// Some forks label iframe-like widgets as "Frame" / "FrameCommon".
k.includes('frame') ||
k.includes('webview') ||
k.includes('html') ||
k.includes('browser') ||
k.includes('webpage') ||
k.includes('website') ||
// Dashboard/report embeds are almost always iframes.
k.includes('grafana') ||
k.includes('powerbi') ||
k.includes('metabase') ||
k.includes('superset') ||
k.includes('tableau') ||
// Chinese low-code widgets / dashboards sometimes label this more directly.
k.includes('网页') ||
k.includes('嵌入') ||
k.includes('内嵌') ||
k.includes('页面') ||
// Chinese low-code widgets sometimes call this H5.
k === 'h5' ||
k.includes('h5_') ||
k.includes('_h5') ||
k.includes('h5页面') ||
// keep the plain 'web' check last; it's broad and may overlap other widgets.
k === 'web' ||
k.endsWith('_web') ||
k.startsWith('web_') ||
// common embed platforms (usually rendered in an <iframe>)
k.includes('youtube') ||
k.includes('youtu') ||
k.includes('vimeo') ||
k.includes('bilibili') ||
k.includes('youku') ||
k.includes('tencent') ||
k.includes('iqiyi')
);
}
function isVideo(c: GoViewComponentLike): boolean {
const k = keyOf(c);
// goView variants: "Video", "VideoCommon", etc.
if (k === 'video' || k.includes('video')) return true;
// Misspellings / aliases seen in forks.
if (k.includes('vedio')) return true;
// Other common widget key aliases (varies a lot across forks).
if (
k.includes('videoplayer') ||
k.includes('video_player') ||
k.includes('player_video') ||
k.includes('liveplayer') ||
k.includes('live_player') ||
k.includes('streamplayer') ||
k.includes('stream_player')
) {
return true;
}
// Chinese low-code widget names.
if (
k.includes('\u89c6\u9891') ||
k.includes('\u76d1\u63a7') ||
k.includes('\u76f4\u64ad') ||
k.includes('\u6444\u50cf') ||
k.includes('\u6444\u50cf\u5934') ||
k.includes('\u56de\u653e')
) {
return true;
}
// Other names seen in the wild.
return (
k.includes('mp4') ||
k.includes('media') ||
// "player" is broad; keep it but prefer more specific matches above.
k.includes('player') ||
// player implementation names frequently used in low-code widgets
k.includes('flvjs') ||
k.includes('hlsjs') ||
k.includes('dplayer') ||
k.includes('vlc') ||
// streaming/protocol keywords (often used directly as widget keys)
k.includes('stream') ||
k.includes('rtsp') ||
k.includes('rtmp') ||
k.includes('webrtc') ||
k.includes('srt') ||
k.includes('wss') ||
k.includes('ws') ||
k.includes('hls') ||
k.includes('m3u8') ||
k.includes('flv') ||
k.includes('dash') ||
// common low-code names for live streams
k.includes('live') ||
k.includes('camera') ||
k.includes('cctv') ||
k.includes('monitor') ||
k.includes('playback') ||
k.includes('streaming') ||
k.includes('videostream')
);
}
function looksLikeImageOption(option: unknown): boolean {
const url = pickUrlLike(option);
if (!url) return false;
// Common direct image URLs.
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url)) return true;
// data urls
if (/^data:image\//i.test(url)) return true;
return false;
}
function looksLikeTextOption(option: unknown): boolean {
if (!option || typeof option !== 'object') return false;
const o = option as Record<string, unknown>;
// goView TextCommon exports usually include a string dataset plus some typography-ish keys.
if (typeof o.dataset !== 'string') return false;
for (const key of [
'fontSize',
'fontColor',
'fontWeight',
'letterSpacing',
'paddingX',
'paddingY',
'textAlign',
'backgroundColor',
'borderWidth',
'borderColor',
'borderRadius',
'writingMode',
]) {
if (key in o) return true;
}
return false;
}
function hasAnyKeyDeep(input: unknown, keys: string[], depth = 2): boolean {
if (!input || typeof input !== 'object') return false;
const obj = input as Record<string, unknown>;
for (const k of keys) {
if (k in obj) return true;
}
if (depth <= 0) return false;
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
if (hasAnyKeyDeep(obj[nestKey], keys, depth - 1)) return true;
}
return false;
}
function containsVideoHtmlDeep(input: unknown, depth = 2): boolean {
if (!input || typeof input !== 'object') return false;
const obj = input as Record<string, unknown>;
for (const key of [
'srcdoc',
'srcDoc',
'html',
'htmlContent',
'htmlString',
'iframeHtml',
'embedHtml',
'embedCode',
'iframeCode',
'iframeEmbed',
'embed',
'code',
'content',
'template',
]) {
const v = obj[key];
if (typeof v === 'string') {
const s = v.trim();
if (s.startsWith('<') && s.includes('>') && (/\bvideo\b/i.test(s) || /\bsource\b/i.test(s))) {
return true;
}
}
}
if (depth <= 0) return false;
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
if (containsVideoHtmlDeep(obj[nestKey], depth - 1)) return true;
}
return false;
}
function looksLikeIframeOption(option: unknown): boolean {
if (!option) return false;
// Avoid false positives: some TextCommon widgets carry a link URL (or other URL-ish fields)
// but are still clearly text.
if (looksLikeTextOption(option)) return false;
if (typeof option === 'object') {
const typeHint = pickStringLikeDeep(
option,
['type', 'widgetType', 'componentType', 'mediaType', 'contentType', 'embedType', 'frameType'],
2,
);
if (typeHint) {
const t = typeHint.toLowerCase();
if (/(video|mp4|m3u8|flv|hls|rtsp|rtmp|webrtc|stream|live)/i.test(t)) return false;
if (/(iframe|embed|webview|web|html|page|h5|browser)/i.test(t)) return true;
}
}
if (typeof option === 'string') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) {
// If the HTML is a video tag, let the video detector handle it.
if (/\bvideo\b/i.test(trimmed) || /\bsource\b/i.test(trimmed)) return false;
return true;
}
}
// Prefer explicit iframe-ish keys when option is an object (including nested shapes).
if (typeof option === 'object') {
// If the embed/html content is clearly a <video> snippet, don't classify as iframe.
// (Some exporters put a <video> tag under fields like `html` / `code`.)
if (containsVideoHtmlDeep(option, 2)) return false;
if (
hasAnyKeyDeep(
option,
[
'iframeUrl',
'iframeSrc',
'iframe',
'embedUrl',
'embedSrc',
'frameUrl',
'frameSrc',
'webUrl',
'websiteUrl',
'siteUrl',
'openUrl',
'openURL',
'linkUrl',
'linkURL',
'targetUrl',
'targetURL',
'webpageUrl',
'pageUrl',
'h5Url',
'link',
'href',
// list-ish (keep iframe-specific only; generic list keys are too common in Video widgets)
'iframeList',
],
3,
) ||
// Some exports store raw HTML instead of a URL.
hasAnyKeyDeep(
option,
[
'srcdoc',
'srcDoc',
'html',
'htmlContent',
'htmlString',
'iframe',
'iframeHtml',
'embedHtml',
'embedCode',
'iframeCode',
'iframeEmbed',
'embed',
'code',
'content',
'template',
],
3,
)
) {
return true;
}
}
const url = pickUrlLike(option);
if (!url) return false;
// If it isn't an obvious media URL, it's often an iframe/embed.
// (We deliberately keep this conservative; image/video are handled earlier.)
return (
(
/^https?:\/\//i.test(url) ||
// protocol-relative URLs (//example.com)
/^\/\//.test(url) ||
/^data:text\/html/i.test(url)
) &&
!/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url) &&
// Avoid misclassifying video streams as iframes.
!/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(url) &&
!/\bm3u8\b/i.test(url) &&
!/^(rtsp|rtmp|webrtc|srt):\/\//i.test(url) &&
!/^wss?:\/\//i.test(url)
);
}
function looksLikeVideoOption(option: unknown): boolean {
if (!option) return false;
// Avoid false positives: some TextCommon widgets carry link-like fields.
if (looksLikeTextOption(option)) return false;
if (typeof option === 'object') {
const typeHint = pickStringLikeDeep(
option,
['type', 'widgetType', 'componentType', 'mediaType', 'contentType', 'playerType', 'streamType'],
2,
);
if (typeHint) {
const t = typeHint.toLowerCase();
if (/(iframe|embed|webview|web|html|page|h5|browser)/i.test(t)) return false;
if (/(video|mp4|m3u8|flv|hls|rtsp|rtmp|webrtc|stream|live|camera|cctv)/i.test(t)) return true;
}
}
if (typeof option === 'string') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) {
return /\bvideo\b/i.test(trimmed) || /\bsource\b/i.test(trimmed);
}
}
// Prefer explicit video-ish keys when option is an object (including nested shapes).
if (typeof option === 'object') {
// Some exporters store a full <video> snippet under html-ish keys (html/code/template/etc).
// Treat that as a strong signal for a video widget.
if (containsVideoHtmlDeep(option, 2)) return true;
if (
hasAnyKeyDeep(
option,
[
'videoUrl',
'videoSrc',
'playUrl',
'srcUrl',
'sourceUrl',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'liveUrl',
'stream',
'streamUrl',
// very common forks / camera widgets
'rtspUrl',
'rtmpUrl',
'hlsUrl',
'flvUrl',
'm3u8Url',
'webrtcUrl',
'rtcUrl',
'wsUrl',
'srtUrl',
'dashUrl',
'cameraUrl',
'cctvUrl',
'monitorUrl',
// format keys
'mp4',
'm3u8',
'flv',
'hls',
'rtsp',
'rtmp',
// list-ish shapes
'sources',
'sourceList',
'urlList',
'srcList',
'playlist',
'playList',
'videos',
'videoList',
'items',
// playback flags
'autoplay',
'autoPlay',
'isAutoPlay',
'controls',
'showControls',
'showControl',
// common UI fields
'poster',
'posterUrl',
],
3,
)
) {
return true;
}
}
const url = pickUrlLike(option);
if (!url) return false;
// Common direct URL patterns.
if (/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(url)) return true;
if (/\bm3u8\b/i.test(url)) return true;
// Streaming-ish protocols (seen in CCTV/camera widgets).
if (/^(rtsp|rtmp|webrtc|srt):\/\//i.test(url)) return true;
// WebSocket-based stream relays.
if (/^wss?:\/\//i.test(url)) return true;
return false;
}
function pick<T>(...values: Array<T | undefined | null>): T | undefined {
for (const v of values) {
if (v !== undefined && v !== null) return v as T;
}
return undefined;
}
function optionOf(cIn: GoViewComponentLike): unknown {
const c = unwrapComponent(cIn);
const chartData = c.chartConfig?.data as Record<string, unknown> | undefined;
return (
c.option ??
c.chartConfig?.option ??
chartData?.option ??
chartData?.options ??
chartData?.config ??
chartData ??
{}
);
}
function looksLikeJsonString(input: string): boolean {
const s = input.trim();
if (!s) return false;
// Avoid parsing obvious URLs.
if (/^(https?:\/\/|\/\/|data:|rtsp:\/\/|rtmp:\/\/|webrtc:\/\/|srt:\/\/|wss?:\/\/)/i.test(s)) return false;
return (
(s.startsWith('{') && s.endsWith('}')) ||
(s.startsWith('[') && s.endsWith(']'))
);
}
function normalizeOption(option: unknown): unknown {
// Some exports accidentally store option blobs as JSON strings.
if (typeof option === 'string' && looksLikeJsonString(option)) {
try {
return JSON.parse(option) as unknown;
} catch {
return option;
}
}
return option;
}
function toMaybeString(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
return undefined;
}
function pickStringLikeDeep(input: unknown, keys: string[], depth = 2): string | undefined {
if (!input || typeof input !== 'object') return toMaybeString(input);
const obj = input as Record<string, unknown>;
for (const key of keys) {
const v = toMaybeString(obj[key]);
if (v) return v;
}
if (depth <= 0) return undefined;
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const nested = pickStringLikeDeep(obj[nestKey], keys, depth - 1);
if (nested) return nested;
}
return undefined;
}
function toNumber(v: unknown, fallback: number): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return fallback;
}
function toMaybeNumber(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return undefined;
}
function pickNumberLike(input: unknown, keys: string[], maxDepth = 2): number | undefined {
return pickNumberLikeInner(input, keys, maxDepth);
}
function pickNumberLikeInner(input: unknown, keys: string[], depth: number): number | undefined {
if (!input) return undefined;
if (typeof input !== 'object') return toMaybeNumber(input);
const obj = input as Record<string, unknown>;
for (const key of keys) {
const v = toMaybeNumber(obj[key]);
if (v !== undefined) return v;
}
if (depth <= 0) return undefined;
for (const key of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const nested = pickNumberLikeInner(obj[key], keys, depth - 1);
if (nested !== undefined) return nested;
}
return undefined;
}
function looksLikeHtml(input: string): boolean {
const s = input.trim();
return s.startsWith('<') && s.includes('>');
}
function extractHtmlAttribute(html: string, name: string): string | undefined {
// eslint-disable-next-line no-useless-escape
const re = new RegExp(`\\b${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i');
const match = re.exec(html);
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function parseHtmlSizeAttr(value: string | undefined): number | undefined {
if (!value) return undefined;
const v = value.trim();
// only accept plain numbers or px values; ignore %/auto/etc.
const m = /^([0-9]+(?:\.[0-9]+)?)(px)?$/i.exec(v);
if (!m) return undefined;
const n = Number(m[1]);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
function pickHtmlStringDeep(input: unknown, depth = 2): string | undefined {
if (typeof input === 'string') return looksLikeHtml(input) ? input : undefined;
if (!input || typeof input !== 'object') return undefined;
const obj = input as Record<string, unknown>;
for (const key of [
'html',
'htmlContent',
'htmlString',
'iframeHtml',
'embedHtml',
'embedCode',
'iframeCode',
'iframeEmbed',
'embed',
'code',
'content',
'template',
'srcdoc',
'srcDoc',
]) {
const v = obj[key];
if (typeof v === 'string' && looksLikeHtml(v)) return v;
}
if (depth <= 0) return undefined;
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const v = pickHtmlStringDeep(obj[nestKey], depth - 1);
if (v) return v;
}
return undefined;
}
function pickSizeLike(option: unknown): { w?: number; h?: number } {
// goView-ish variants use different keys and sometimes nest them under style/config.
const w = pickNumberLike(option, ['width', 'w']);
const h = pickNumberLike(option, ['height', 'h']);
// Some forks store only an embed HTML snippet (iframe/video) with width/height.
// If attr sizing is missing, this helps us pick a better default rect.
const html = pickHtmlStringDeep(option, 3);
if (html) {
const wFromHtml = parseHtmlSizeAttr(extractHtmlAttribute(html, 'width'));
const hFromHtml = parseHtmlSizeAttr(extractHtmlAttribute(html, 'height'));
return { w: w ?? wFromHtml, h: h ?? hFromHtml };
}
return { w, h };
}
function pickAspectRatioLike(option: unknown): number | undefined {
const ratio = pickNumberLike(option, ['aspectRatio', 'aspect', 'ratio', 'aspect_ratio']);
if (ratio === undefined) return undefined;
return ratio > 0 ? ratio : undefined;
}
function applyAspectRatioToSize(
size: { w?: number; h?: number },
aspectRatio?: number,
): { w?: number; h?: number } {
if (!aspectRatio || aspectRatio <= 0) return size;
if (size.w && !size.h) return { ...size, h: Math.round(size.w / aspectRatio) };
if (size.h && !size.w) return { ...size, w: Math.round(size.h * aspectRatio) };
return size;
}
function normalizeRect(
rect: { x: number; y: number; w: number; h: number },
fallback: { w: number; h: number },
): { x: number; y: number; w: number; h: number } {
const w = rect.w > 0 ? rect.w : fallback.w;
const h = rect.h > 0 ? rect.h : fallback.h;
return { x: rect.x, y: rect.y, w, h };
}
export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen {
// goView exports vary a lot; attempt a few common nesting shapes.
const root = input as unknown as Record<string, unknown>;
const data = (root.data as Record<string, unknown> | undefined) ?? undefined;
const state = (root.state as Record<string, unknown> | undefined) ?? undefined;
const project = (root.project as Record<string, unknown> | undefined) ?? undefined;
const editCanvasConfig = pick<GoViewEditCanvasConfigLike>(
(input as GoViewStorageLike).editCanvasConfig,
data?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
state?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
project?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined,
);
const width = Math.max(
1,
toNumber(
editCanvasConfig?.width ??
(input as GoViewProjectLike).canvas?.width ??
(input as GoViewProjectLike).width ??
(data?.width as number | string | undefined) ??
1920,
1920,
),
);
const height = Math.max(
1,
toNumber(
editCanvasConfig?.height ??
(input as GoViewProjectLike).canvas?.height ??
(input as GoViewProjectLike).height ??
(data?.height as number | string | undefined) ??
1080,
1080,
),
);
const name = editCanvasConfig?.projectName ?? 'Imported Project';
const background = editCanvasConfig?.background;
const screen = createEmptyScreen({
version: ASTRALVIEW_SCHEMA_VERSION,
width,
height,
name,
background: background ? { color: background } : undefined,
nodes: [],
});
const componentListRaw =
(input as GoViewStorageLike).componentList ??
(input as GoViewProjectLike).componentList ??
(data?.componentList as GoViewComponentLike[] | undefined) ??
(state?.componentList as GoViewComponentLike[] | undefined) ??
(project?.componentList as GoViewComponentLike[] | undefined) ??
[];
const componentList = Array.isArray(componentListRaw) ? componentListRaw : [];
const nodes: WidgetNode[] = [];
for (const raw of componentList) {
if (!raw || typeof raw !== 'object') continue;
const c = unwrapComponent(raw);
const option = normalizeOption(optionOf(c));
// We try to infer the widget kind early so we can pick better default sizes
// when exports omit sizing information.
// Important: run media/embed checks before text checks.
// Some goView/fork widgets have misleading keys that contain "text" even though
// the option payload is clearly video/iframe.
// Prefer evidence from the option payload over the widget key.
// Some goView/fork widgets mislabel embedded platforms (YouTube/Vimeo/etc) as "Video" even
// though they are best represented as an iframe.
const optionSaysImage = looksLikeImageOption(option);
const optionSaysVideo = looksLikeVideoOption(option);
const optionSaysIframe = looksLikeIframeOption(option);
// Some goView/fork widgets mislabel embedded platforms (YouTube/Vimeo/etc) as "Video"
// even though they are best represented as an iframe (a web page, not a media stream).
const urlLike = pickUrlLike(option);
const urlLooksLikeEmbedPage =
!!urlLike &&
/(youtube\.com|youtu\.be|vimeo\.com|twitch\.tv|tiktok\.com|douyin\.com|kuaishou\.com|ixigua\.com|huya\.com|douyu\.com|bilibili\.com|live\.bilibili\.com|player\.bilibili\.com|youku\.com|iqiyi\.com|mgtv\.com|tencent|qq\.com|music\.163\.com|spotify\.com|soundcloud\.com|figma\.com|google\.com\/maps|amap\.com|gaode\.com|map\.baidu\.com)/i.test(
urlLike,
) &&
// Keep actual media URLs as video.
!/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(urlLike) &&
!/^data:video\//i.test(urlLike) &&
!/^(rtsp|rtmp|webrtc|srt):\/\//i.test(urlLike) &&
!/^wss?:\/\//i.test(urlLike);
// Prefer evidence from the option payload over the widget key.
// Many forks mislabel widget keys (e.g. "Text*" / "Image*"), but the option payload
// clearly indicates iframe/video/image.
const inferredType: 'text' | 'image' | 'iframe' | 'video' | undefined =
optionSaysIframe
? 'iframe'
: optionSaysVideo
? urlLooksLikeEmbedPage
? 'iframe'
: 'video'
: optionSaysImage
? 'image'
: isIframe(c)
? 'iframe'
: isVideo(c)
? 'video'
: isImage(c)
? 'image'
: isTextCommon(c)
? 'text'
: undefined;
const defaults =
inferredType === 'text'
? { w: 320, h: 60 }
: inferredType === 'image'
? { w: 320, h: 180 }
: inferredType === 'iframe'
? { w: 480, h: 270 }
: inferredType === 'video'
? { w: 480, h: 270 }
: { w: 320, h: 60 };
const optSize = applyAspectRatioToSize(pickSizeLike(option), pickAspectRatioLike(option));
const attr = c.attr as unknown as Record<string, unknown> | undefined;
const rawRect = attr
? {
x: toNumber(attr.x, 0),
y: toNumber(attr.y, 0),
// Prefer explicit attr sizing, but fall back to option sizing when missing.
w: toNumber(attr.w, optSize.w ?? defaults.w),
h: toNumber(attr.h, optSize.h ?? defaults.h),
}
: { x: 0, y: 0, w: optSize.w ?? defaults.w, h: optSize.h ?? defaults.h };
const rect = normalizeRect(rawRect, defaults);
const zIndex = attr?.zIndex === undefined ? undefined : toNumber(attr.zIndex, 0);
const baseId = toMaybeString(c.id);
if (inferredType === 'text') {
const props = convertGoViewTextOptionToNodeProps(option as GoViewTextOption);
nodes.push({
id: baseId ?? `import_text_${Math.random().toString(16).slice(2)}`,
type: 'text',
rect,
zIndex,
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (inferredType === 'image') {
const props = convertGoViewImageOptionToNodeProps(option as GoViewImageOption);
nodes.push({
id: baseId ?? `import_image_${Math.random().toString(16).slice(2)}`,
type: 'image',
rect,
zIndex,
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (inferredType === 'iframe') {
const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption);
nodes.push({
id: baseId ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
type: 'iframe',
rect,
zIndex,
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
if (inferredType === 'video') {
const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption);
nodes.push({
id: baseId ?? `import_video_${Math.random().toString(16).slice(2)}`,
type: 'video',
rect,
zIndex,
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
});
continue;
}
}
return {
...screen,
nodes,
};
}