File size: 3,588 Bytes
2355b2b
 
 
 
 
 
 
 
 
 
4decad9
 
 
 
 
2355b2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4decad9
2355b2b
 
 
 
 
4decad9
 
 
 
 
2355b2b
 
 
 
 
 
 
 
 
 
 
 
 
49da3aa
 
 
 
 
 
 
 
 
 
 
 
2355b2b
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/**
 * Merge meta.json `layout_prop_schema[layout].defaults` under stored layoutProps,
 * matching `backend/app/services/remotion.py` (`write_remotion_data`, ~983–1002).
 *
 * Keeps frontend preview / SceneEditModal in sync with the rendered MP4 for
 * chart colors, axis captions, example tables, font sizes, etc.
 */

export type LayoutPropSchema = Record<string, { defaults?: Record<string, unknown> }>;

// Economist wordmark/dateline/teasers are LLM-authored content, never defaults —
// mirror `_ECONOMIST_CONTENT_SKIP_KEYS` in backend/app/services/remotion.py so the
// preview never injects the sample "The Brief" wordmark onto the cover / sign-off.
const ECONOMIST_CONTENT_SKIP_KEYS = new Set(["wordmark", "dateline", "teasers"]);

function resolveDefaultsForAspect(
  defaults: Record<string, unknown>,
  aspectRatio: string,
): Record<string, unknown> {
  const ar = aspectRatio === "portrait" ? "portrait" : "landscape";
  const resolved: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(defaults)) {
    if (
      value &&
      typeof value === "object" &&
      !Array.isArray(value) &&
      "portrait" in value &&
      "landscape" in value
    ) {
      const responsive = value as { portrait?: unknown; landscape?: unknown };
      resolved[key] = responsive[ar] ?? responsive.landscape;
    } else {
      resolved[key] = value;
    }
  }
  return resolved;
}

/**
 * Apply layout schema defaults under `layoutProps` (stored props win on conflict).
 */
export function mergeLayoutSchemaDefaults(
  layoutProps: Record<string, unknown>,
  layoutId: string | null | undefined,
  schema: LayoutPropSchema | null | undefined,
  aspectRatio = "landscape",
  templateId?: string | null,
): Record<string, unknown> {
  if (!layoutId || !schema || Object.keys(schema).length === 0) return layoutProps;
  const defaults = schema[layoutId]?.defaults;
  if (!defaults || Object.keys(defaults).length === 0) return layoutProps;
  const resolved = resolveDefaultsForAspect(defaults, aspectRatio);
  // Drop content-only defaults the renderer also refuses to inject, so the
  // preview matches the MP4 (e.g. economist never auto-stamps a brand wordmark).
  if (String(templateId ?? "").toLowerCase() === "economist") {
    for (const key of ECONOMIST_CONTENT_SKIP_KEYS) delete resolved[key];
  }
  const merged = { ...resolved, ...layoutProps };

  // Keep example chart data when stored chartTable has no rows (legacy partial merge).
  const existingTable = layoutProps.chartTable;
  const existingTableHasRows =
    existingTable &&
    typeof existingTable === "object" &&
    Array.isArray((existingTable as { rows?: unknown }).rows) &&
    ((existingTable as { rows: unknown[] }).rows.length > 0);
  if (!existingTableHasRows && resolved.chartTable && typeof resolved.chartTable === "object") {
    merged.chartTable = resolved.chartTable;
  }

  const existingTickerTable = layoutProps.tickerTable;
  const existingTickerTableHasRows =
    existingTickerTable &&
    typeof existingTickerTable === "object" &&
    Array.isArray((existingTickerTable as { rows?: unknown }).rows) &&
    ((existingTickerTable as { rows: unknown[] }).rows.some((row) =>
      Array.isArray(row) && row.some((cell) => String(cell ?? "").trim() !== ""),
    ));
  if (!existingTickerTableHasRows && resolved.tickerTable && typeof resolved.tickerTable === "object") {
    merged.tickerTable = resolved.tickerTable;
  }

  if (!("chartType" in layoutProps) && typeof resolved.chartType === "string") {
    merged.chartType = resolved.chartType;
  }

  return merged;
}