@gannochenko/staticstripes 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/Makefile +8 -0
  2. package/dist/app-builder.d.ts +18 -0
  3. package/dist/app-builder.d.ts.map +1 -0
  4. package/dist/app-builder.js +94 -0
  5. package/dist/app-builder.js.map +1 -0
  6. package/dist/cli/commands/filters.d.ts +3 -0
  7. package/dist/cli/commands/filters.d.ts.map +1 -0
  8. package/dist/cli/commands/filters.js +21 -0
  9. package/dist/cli/commands/filters.js.map +1 -0
  10. package/dist/cli/commands/generate.d.ts.map +1 -1
  11. package/dist/cli/commands/generate.js +6 -1
  12. package/dist/cli/commands/generate.js.map +1 -1
  13. package/dist/cli/instagram/instagram-upload-strategy.d.ts +5 -0
  14. package/dist/cli/instagram/instagram-upload-strategy.d.ts.map +1 -1
  15. package/dist/cli/instagram/instagram-upload-strategy.js +46 -3
  16. package/dist/cli/instagram/instagram-upload-strategy.js.map +1 -1
  17. package/dist/cli.js +2 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/ffmpeg.d.ts +32 -0
  20. package/dist/ffmpeg.d.ts.map +1 -1
  21. package/dist/ffmpeg.js +118 -0
  22. package/dist/ffmpeg.js.map +1 -1
  23. package/dist/html-project-parser.d.ts +36 -1
  24. package/dist/html-project-parser.d.ts.map +1 -1
  25. package/dist/html-project-parser.js +332 -15
  26. package/dist/html-project-parser.js.map +1 -1
  27. package/dist/project.d.ts +4 -1
  28. package/dist/project.d.ts.map +1 -1
  29. package/dist/project.js +50 -1
  30. package/dist/project.js.map +1 -1
  31. package/dist/sample-sequences.d.ts.map +1 -1
  32. package/dist/sample-sequences.js +293 -0
  33. package/dist/sample-sequences.js.map +1 -1
  34. package/dist/sequence.d.ts +4 -1
  35. package/dist/sequence.d.ts.map +1 -1
  36. package/dist/sequence.js +71 -21
  37. package/dist/sequence.js.map +1 -1
  38. package/dist/stream.d.ts +17 -0
  39. package/dist/stream.d.ts.map +1 -1
  40. package/dist/stream.js +28 -0
  41. package/dist/stream.js.map +1 -1
  42. package/dist/type.d.ts +29 -2
  43. package/dist/type.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/app-builder.ts +113 -0
  46. package/src/cli/commands/filters.ts +21 -0
  47. package/src/cli/commands/generate.ts +10 -1
  48. package/src/cli/instagram/instagram-upload-strategy.ts +61 -1
  49. package/src/cli.ts +2 -0
  50. package/src/ffmpeg.ts +161 -0
  51. package/src/html-project-parser.ts +410 -28
  52. package/src/project.ts +62 -0
  53. package/src/sample-sequences.ts +300 -0
  54. package/src/sequence.ts +78 -22
  55. package/src/stream.ts +50 -0
  56. package/src/type.ts +31 -2
@@ -1033,6 +1033,7 @@ export class HTMLProjectParser {
1033
1033
  let thumbOffset: number | undefined;
1034
1034
  let coverUrl: string | undefined;
1035
1035
  let videoUrl: string | undefined;
1036
+ let locationId: string | undefined;
1036
1037
  const localTags: string[] = [];
1037
1038
 
1038
1039
  if ('children' in element && element.children) {
@@ -1106,6 +1107,21 @@ export class HTMLProjectParser {
1106
1107
  }
1107
1108
  break;
1108
1109
  }
1110
+ case 'location': {
1111
+ const id = childAttrs.get('id');
1112
+ const city = childAttrs.get('city');
1113
+ const country = childAttrs.get('country');
1114
+
1115
+ if (id) {
1116
+ // Explicit location ID provided
1117
+ locationId = id;
1118
+ } else if (city && country) {
1119
+ // Store search query for later resolution
1120
+ // Format: "search:City, Country"
1121
+ locationId = `search:${city}, ${country}`;
1122
+ }
1123
+ break;
1124
+ }
1109
1125
  }
1110
1126
  }
1111
1127
  }
@@ -1131,6 +1147,7 @@ export class HTMLProjectParser {
1131
1147
  thumbOffset,
1132
1148
  coverUrl,
1133
1149
  videoUrl,
1150
+ locationId,
1134
1151
  },
1135
1152
  };
1136
1153
  }
@@ -1615,26 +1632,44 @@ export class HTMLProjectParser {
1615
1632
  const container = this.extractFragmentContainer(element);
1616
1633
  const app = container ? undefined : this.extractFragmentApp(element);
1617
1634
 
1618
- // 5. Parse trimLeft from -trim-start property
1619
- const trimLeft = this.parseTrimStart(styles['-trim-start']);
1620
-
1621
- // 5b. Parse trimRight from -trim-end property
1622
- const trimRight = this.parseTrimEnd(styles['-trim-end']);
1623
-
1624
- // 6. Parse duration from -duration property
1625
- const duration = this.parseDurationProperty(
1626
- styles['-duration'],
1627
- assetName,
1628
- assets,
1629
- trimLeft,
1630
- trimRight,
1631
- );
1632
-
1633
- // 7. Parse -offset-start for overlayLeft (can be number or expression)
1634
- const overlayLeft = this.parseOffsetStart(styles['-offset-start']);
1635
-
1636
- // 8. Parse -offset-end for overlayRight (temporary, will be normalized)
1637
- const overlayRight = this.parseOffsetEnd(styles['-offset-end']);
1635
+ // 4b. Parse data-timing attribute (short syntax) - takes precedence over CSS
1636
+ const dataTiming = this.parseDataTiming(attrs.get('data-timing'));
1637
+
1638
+ // 5. Parse trimLeft from data-timing or -trim-start property
1639
+ const trimLeft =
1640
+ dataTiming.trimStart !== undefined
1641
+ ? dataTiming.trimStart
1642
+ : this.parseTrimStart(styles['-trim-start']);
1643
+
1644
+ // 5b. Parse trimRight from data-timing or -trim-end property
1645
+ const trimRight =
1646
+ dataTiming.trimEnd !== undefined
1647
+ ? dataTiming.trimEnd
1648
+ : this.parseTrimEnd(styles['-trim-end']);
1649
+
1650
+ // 6. Parse duration from data-timing or -duration property
1651
+ const duration =
1652
+ dataTiming.duration !== undefined
1653
+ ? dataTiming.duration
1654
+ : this.parseDurationProperty(
1655
+ styles['-duration'],
1656
+ assetName,
1657
+ assets,
1658
+ trimLeft,
1659
+ trimRight,
1660
+ );
1661
+
1662
+ // 7. Parse overlayLeft from data-timing or -offset-start property
1663
+ const overlayLeft =
1664
+ dataTiming.offsetStart !== undefined
1665
+ ? dataTiming.offsetStart
1666
+ : this.parseOffsetStart(styles['-offset-start']);
1667
+
1668
+ // 8. Parse overlayRight from data-timing or -offset-end property
1669
+ const overlayRight =
1670
+ dataTiming.offsetEnd !== undefined
1671
+ ? dataTiming.offsetEnd
1672
+ : this.parseOffsetEnd(styles['-offset-end']);
1638
1673
 
1639
1674
  // 9. Parse -overlay-start-z-index for overlayZIndex
1640
1675
  const overlayZIndex = this.parseZIndex(styles['-overlay-start-z-index']);
@@ -1658,10 +1693,16 @@ export class HTMLProjectParser {
1658
1693
  // 14. Parse -chromakey
1659
1694
  const chromakeyData = this.parseChromakeyProperty(styles['-chromakey']);
1660
1695
 
1696
+ // 14b. Parse -object-fit-ken-burns
1697
+ const kenBurnsData = this.parseKenBurnsProperty(styles['-object-fit-ken-burns']);
1698
+
1661
1699
  // 15. Parse filter (for visual filters)
1662
1700
  const visualFilter = this.parseVisualFilterProperty(styles['filter']);
1663
1701
 
1664
- // 16. Extract timecode label from data-timecode attribute
1702
+ // 16. Parse sound property (on/off)
1703
+ const sound = this.parseSoundProperty(styles['-sound']);
1704
+
1705
+ // 17. Extract timecode label from data-timecode attribute
1665
1706
  const timecodeLabel = attrs.get('data-timecode') || undefined;
1666
1707
 
1667
1708
  return {
@@ -1692,6 +1733,17 @@ export class HTMLProjectParser {
1692
1733
  chromakeyBlend: chromakeyData.chromakeyBlend,
1693
1734
  chromakeySimilarity: chromakeyData.chromakeySimilarity,
1694
1735
  chromakeyColor: chromakeyData.chromakeyColor,
1736
+ objectFitKenBurns: kenBurnsData.objectFitKenBurns,
1737
+ objectFitKenBurnsZoom: kenBurnsData.objectFitKenBurnsZoom,
1738
+ objectFitKenBurnsEffectDuration: kenBurnsData.objectFitKenBurnsEffectDuration,
1739
+ objectFitKenBurnsEasing: kenBurnsData.objectFitKenBurnsEasing,
1740
+ objectFitKenBurnsFocalX: kenBurnsData.objectFitKenBurnsFocalX,
1741
+ objectFitKenBurnsFocalY: kenBurnsData.objectFitKenBurnsFocalY,
1742
+ objectFitKenBurnsPanStartX: kenBurnsData.objectFitKenBurnsPanStartX,
1743
+ objectFitKenBurnsPanStartY: kenBurnsData.objectFitKenBurnsPanStartY,
1744
+ objectFitKenBurnsPanEndX: kenBurnsData.objectFitKenBurnsPanEndX,
1745
+ objectFitKenBurnsPanEndY: kenBurnsData.objectFitKenBurnsPanEndY,
1746
+ sound,
1695
1747
  ...(visualFilter && { visualFilter }), // Add visualFilter if present
1696
1748
  ...(container && { container }), // Add container if present
1697
1749
  ...(app && { app }), // Add app if present
@@ -1718,6 +1770,25 @@ export class HTMLProjectParser {
1718
1770
  return trimmed || undefined;
1719
1771
  }
1720
1772
 
1773
+ /**
1774
+ * Parses -sound property
1775
+ * Can be: "on" (default) or "off"
1776
+ * When "off", replaces audio stream with silence
1777
+ */
1778
+ private parseSoundProperty(sound: string | undefined): 'on' | 'off' {
1779
+ if (!sound) {
1780
+ return 'on'; // Default: use audio
1781
+ }
1782
+
1783
+ const trimmed = sound.trim().toLowerCase();
1784
+
1785
+ if (trimmed === 'off') {
1786
+ return 'off';
1787
+ }
1788
+
1789
+ return 'on'; // Default for any other value
1790
+ }
1791
+
1721
1792
  /**
1722
1793
  * Extracts the first <container> child from a fragment element
1723
1794
  */
@@ -1919,7 +1990,7 @@ export class HTMLProjectParser {
1919
1990
  assets: Map<string, Asset>,
1920
1991
  trimLeft: number,
1921
1992
  trimRight: number,
1922
- ): number {
1993
+ ): number | CompiledExpression {
1923
1994
  if (!duration || duration.trim() === 'auto') {
1924
1995
  // Auto: use asset duration minus trim-start and trim-end
1925
1996
  const asset = assets.get(assetName);
@@ -1929,9 +2000,16 @@ export class HTMLProjectParser {
1929
2000
  return Math.max(0, asset.duration - trimLeft - trimRight);
1930
2001
  }
1931
2002
 
2003
+ const trimmed = duration.trim();
2004
+
2005
+ // Check if it's a calc() expression
2006
+ if (trimmed.startsWith('calc(')) {
2007
+ return parseValueLazy(trimmed) as CompiledExpression;
2008
+ }
2009
+
1932
2010
  // Handle percentage (e.g., "100%", "50%")
1933
- if (duration.endsWith('%')) {
1934
- const percentage = parseFloat(duration);
2011
+ if (trimmed.endsWith('%')) {
2012
+ const percentage = parseFloat(trimmed);
1935
2013
  if (isNaN(percentage)) {
1936
2014
  return 0;
1937
2015
  }
@@ -1946,12 +2024,12 @@ export class HTMLProjectParser {
1946
2024
  }
1947
2025
 
1948
2026
  // Handle time value (e.g., "5000ms", "5s")
1949
- return this.parseMilliseconds(duration);
2027
+ return this.parseMilliseconds(trimmed);
1950
2028
  }
1951
2029
 
1952
2030
  /**
1953
2031
  * Parses time value into milliseconds
1954
- * Supports: "5s", "5000ms", "1.5s", etc.
2032
+ * Supports: "5s", "5000ms", "1.5s", or raw numbers (defaults to milliseconds)
1955
2033
  */
1956
2034
  private parseMilliseconds(value: string | undefined): number {
1957
2035
  if (!value) {
@@ -1976,6 +2054,12 @@ export class HTMLProjectParser {
1976
2054
  }
1977
2055
  }
1978
2056
 
2057
+ // Handle raw numbers (default to milliseconds)
2058
+ const num = parseFloat(trimmed);
2059
+ if (!isNaN(num)) {
2060
+ return Math.round(num);
2061
+ }
2062
+
1979
2063
  return 0;
1980
2064
  }
1981
2065
 
@@ -2023,6 +2107,81 @@ export class HTMLProjectParser {
2023
2107
  return this.parseMilliseconds(trimmed);
2024
2108
  }
2025
2109
 
2110
+ /**
2111
+ * Parses data-timing attribute with short syntax
2112
+ * Format: "ts=3000,te=5000,d=2000,os=1000,oe=7000"
2113
+ * Where:
2114
+ * ts = -trim-start
2115
+ * te = -trim-end
2116
+ * d = -duration
2117
+ * os = -offset-start
2118
+ * oe = -offset-end
2119
+ * Values default to milliseconds if no unit specified
2120
+ * Supports calc() expressions: ts=calc(url(#id.time.start))
2121
+ */
2122
+ private parseDataTiming(dataTiming: string | undefined): {
2123
+ trimStart?: number;
2124
+ trimEnd?: number;
2125
+ duration?: number | CompiledExpression;
2126
+ offsetStart?: number | CompiledExpression;
2127
+ offsetEnd?: number | CompiledExpression;
2128
+ } {
2129
+ const result: {
2130
+ trimStart?: number;
2131
+ trimEnd?: number;
2132
+ duration?: number | CompiledExpression;
2133
+ offsetStart?: number | CompiledExpression;
2134
+ offsetEnd?: number | CompiledExpression;
2135
+ } = {};
2136
+
2137
+ if (!dataTiming) {
2138
+ return result;
2139
+ }
2140
+
2141
+ // Split by comma and parse each key-value pair
2142
+ const pairs = dataTiming.split(',').map((pair) => pair.trim());
2143
+
2144
+ for (const pair of pairs) {
2145
+ const [key, value] = pair.split('=').map((s) => s.trim());
2146
+ if (!key || !value) {
2147
+ continue;
2148
+ }
2149
+
2150
+ // Parse value - check if it's a calc() expression or a simple value
2151
+ let parsedValue: number | CompiledExpression;
2152
+
2153
+ if (value.startsWith('calc(')) {
2154
+ // It's a calc() expression
2155
+ parsedValue = parseValueLazy(value) as CompiledExpression;
2156
+ } else {
2157
+ // It's a simple time value - parse with parseMilliseconds
2158
+ // which handles units like 's' and 'ms', defaulting to ms
2159
+ parsedValue = this.parseMilliseconds(value);
2160
+ }
2161
+
2162
+ // Map short names to result properties
2163
+ switch (key) {
2164
+ case 'ts':
2165
+ result.trimStart = parsedValue as number;
2166
+ break;
2167
+ case 'te':
2168
+ result.trimEnd = parsedValue as number;
2169
+ break;
2170
+ case 'd':
2171
+ result.duration = parsedValue;
2172
+ break;
2173
+ case 'os':
2174
+ result.offsetStart = parsedValue;
2175
+ break;
2176
+ case 'oe':
2177
+ result.offsetEnd = parsedValue;
2178
+ break;
2179
+ }
2180
+ }
2181
+
2182
+ return result;
2183
+ }
2184
+
2026
2185
  /**
2027
2186
  * Parses z-index values (-overlay-start-z-index, -overlay-end-z-index)
2028
2187
  */
@@ -2073,7 +2232,7 @@ export class HTMLProjectParser {
2073
2232
  * - "cover"
2074
2233
  */
2075
2234
  private parseObjectFitProperty(objectFit: string | undefined): {
2076
- objectFit: 'cover' | 'contain';
2235
+ objectFit: 'cover' | 'contain' | 'ken-burns';
2077
2236
  objectFitContain: 'ambient' | 'pillarbox';
2078
2237
  objectFitContainAmbientBlurStrength: number;
2079
2238
  objectFitContainAmbientBrightness: number;
@@ -2082,7 +2241,7 @@ export class HTMLProjectParser {
2082
2241
  } {
2083
2242
  // Defaults
2084
2243
  const defaults = {
2085
- objectFit: 'cover' as 'cover' | 'contain',
2244
+ objectFit: 'cover' as 'cover' | 'contain' | 'ken-burns',
2086
2245
  objectFitContain: 'ambient' as 'ambient' | 'pillarbox',
2087
2246
  objectFitContainAmbientBlurStrength: 20,
2088
2247
  objectFitContainAmbientBrightness: -0.3,
@@ -2108,6 +2267,11 @@ export class HTMLProjectParser {
2108
2267
  return { ...defaults, objectFit: 'cover' };
2109
2268
  }
2110
2269
 
2270
+ // Handle "ken-burns"
2271
+ if (type === 'ken-burns') {
2272
+ return { ...defaults, objectFit: 'ken-burns' };
2273
+ }
2274
+
2111
2275
  // Handle "contain" with sub-options
2112
2276
  if (type === 'contain') {
2113
2277
  const subType = parts[1];
@@ -2223,4 +2387,222 @@ export class HTMLProjectParser {
2223
2387
  chromakeyColor: color,
2224
2388
  };
2225
2389
  }
2390
+
2391
+ /**
2392
+ * Parses -object-fit-ken-burns property
2393
+ * Format:
2394
+ * Zoom effects: "<effect> <focal-x> <focal-y> <zoom%> <duration> [easing]"
2395
+ * Pan effects: "<effect> <zoom-factor> [easing]"
2396
+ * Examples:
2397
+ * - "zoom-in 50% 50% 30% 1000ms ease-in-out" - zoom 30% over 1s
2398
+ * - "zoom-out 30% 30% 50% 2000ms ease-out" - zoom out 50% over 2s
2399
+ * - "pan-left 1.3 linear" - pan with 1.3x zoom
2400
+ * Effects: zoom-in, zoom-out (require focal points + zoom% + duration)
2401
+ * pan-left, pan-right, pan-top, pan-bottom (require zoom factor only)
2402
+ * Zoom: for zoom effects: percentage (30 = 30%), for pan: factor (1.3)
2403
+ * Duration: effect duration in milliseconds (zoom effects only)
2404
+ * Easing: linear, ease-in, ease-out, ease-in-out (default: linear)
2405
+ */
2406
+ private parseKenBurnsProperty(kenBurns: string | undefined): {
2407
+ objectFitKenBurns: 'zoom-in' | 'zoom-out' | 'pan-left' | 'pan-right' | 'pan-top' | 'pan-bottom';
2408
+ objectFitKenBurnsZoom: number;
2409
+ objectFitKenBurnsEffectDuration: number;
2410
+ objectFitKenBurnsEasing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
2411
+ objectFitKenBurnsFocalX: number;
2412
+ objectFitKenBurnsFocalY: number;
2413
+ objectFitKenBurnsPanStartX: number;
2414
+ objectFitKenBurnsPanStartY: number;
2415
+ objectFitKenBurnsPanEndX: number;
2416
+ objectFitKenBurnsPanEndY: number;
2417
+ } {
2418
+ // Defaults
2419
+ const defaults = {
2420
+ objectFitKenBurns: 'zoom-in' as 'zoom-in' | 'zoom-out' | 'pan-left' | 'pan-right' | 'pan-top' | 'pan-bottom',
2421
+ objectFitKenBurnsZoom: 30, // 30% zoom
2422
+ objectFitKenBurnsEffectDuration: 0, // 0 = use fragment duration
2423
+ objectFitKenBurnsEasing: 'linear' as 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out',
2424
+ objectFitKenBurnsFocalX: 50, // center (for zoom effects)
2425
+ objectFitKenBurnsFocalY: 50, // center (for zoom effects)
2426
+ objectFitKenBurnsPanStartX: 0, // left edge (for horizontal pan)
2427
+ objectFitKenBurnsPanStartY: 0, // top edge (for vertical pan)
2428
+ objectFitKenBurnsPanEndX: 100, // right edge (for horizontal pan)
2429
+ objectFitKenBurnsPanEndY: 100, // bottom edge (for vertical pan)
2430
+ };
2431
+
2432
+ if (!kenBurns) {
2433
+ return defaults;
2434
+ }
2435
+
2436
+ const trimmed = kenBurns.trim();
2437
+ const parts = this.splitCssValue(trimmed);
2438
+
2439
+ if (parts.length === 0) {
2440
+ return defaults;
2441
+ }
2442
+
2443
+ // Parse effect (first part)
2444
+ const effect = parts[0] as 'zoom-in' | 'zoom-out' | 'pan-left' | 'pan-right' | 'pan-top' | 'pan-bottom';
2445
+ const validEffects = ['zoom-in', 'zoom-out', 'pan-left', 'pan-right', 'pan-top', 'pan-bottom'];
2446
+ if (!validEffects.includes(effect)) {
2447
+ return defaults;
2448
+ }
2449
+
2450
+ let focalX = defaults.objectFitKenBurnsFocalX;
2451
+ let focalY = defaults.objectFitKenBurnsFocalY;
2452
+ let zoom = defaults.objectFitKenBurnsZoom;
2453
+ let effectDuration = defaults.objectFitKenBurnsEffectDuration;
2454
+ let easing = defaults.objectFitKenBurnsEasing;
2455
+ let panStartX = defaults.objectFitKenBurnsPanStartX;
2456
+ let panStartY = defaults.objectFitKenBurnsPanStartY;
2457
+ let panEndX = defaults.objectFitKenBurnsPanEndX;
2458
+ let panEndY = defaults.objectFitKenBurnsPanEndY;
2459
+
2460
+ const isZoomEffect = effect === 'zoom-in' || effect === 'zoom-out';
2461
+ const isPanEffect = effect === 'pan-left' || effect === 'pan-right' || effect === 'pan-top' || effect === 'pan-bottom';
2462
+
2463
+ // Parse remaining parts based on effect type
2464
+ let nextIndex = 1;
2465
+
2466
+ if (isZoomEffect) {
2467
+ // Zoom effects: "<effect> <focal-x%> <focal-y%> <zoom%> <duration> [easing]"
2468
+
2469
+ // Parse focal points
2470
+ if (parts.length >= 3 && parts[1].includes('%') && parts[2].includes('%')) {
2471
+ const focalXStr = parts[1].split('%')[0];
2472
+ const focalYStr = parts[2].split('%')[0];
2473
+ const parsedX = parseFloat(focalXStr);
2474
+ const parsedY = parseFloat(focalYStr);
2475
+
2476
+ if (!isNaN(parsedX) && !isNaN(parsedY)) {
2477
+ focalX = Math.max(0, Math.min(100, parsedX));
2478
+ focalY = Math.max(0, Math.min(100, parsedY));
2479
+ nextIndex = 3;
2480
+
2481
+ // Check if parts[2] had a concatenated value after '%'
2482
+ const remainder = parts[2].split('%')[1];
2483
+ if (remainder && remainder.trim()) {
2484
+ parts.splice(3, 0, remainder.trim());
2485
+ }
2486
+ }
2487
+ }
2488
+
2489
+ // Parse zoom percentage (required)
2490
+ if (parts.length > nextIndex && parts[nextIndex].includes('%')) {
2491
+ const zoomStr = parts[nextIndex].split('%')[0];
2492
+ const parsedZoom = parseFloat(zoomStr);
2493
+ if (!isNaN(parsedZoom) && parsedZoom >= 0) {
2494
+ zoom = parsedZoom; // Store as percentage (e.g., 30 for 30%)
2495
+ nextIndex++;
2496
+
2497
+ // Check for concatenated value after '%'
2498
+ const remainder = parts[nextIndex - 1].split('%')[1];
2499
+ if (remainder && remainder.trim()) {
2500
+ parts.splice(nextIndex, 0, remainder.trim());
2501
+ }
2502
+ }
2503
+ }
2504
+
2505
+ // Parse effect duration (required)
2506
+ if (parts.length > nextIndex) {
2507
+ const durationStr = parts[nextIndex];
2508
+ // Simple inline parser for "1000ms" or "1s" format
2509
+ const match = durationStr.match(/^(\d+(?:\.\d+)?)(ms|s)$/);
2510
+ if (match) {
2511
+ const value = parseFloat(match[1]);
2512
+ const parsedDuration = match[2] === 's' ? value * 1000 : value;
2513
+ if (parsedDuration > 0) {
2514
+ effectDuration = parsedDuration;
2515
+ nextIndex++;
2516
+ }
2517
+ }
2518
+ }
2519
+
2520
+ // Parse easing (optional)
2521
+ if (parts.length > nextIndex) {
2522
+ const easingStr = parts[nextIndex] as 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
2523
+ if (['linear', 'ease-in', 'ease-out', 'ease-in-out'].includes(easingStr)) {
2524
+ easing = easingStr;
2525
+ }
2526
+ }
2527
+ } else if (isPanEffect) {
2528
+ // Pan effects: "<effect> <zoom%> <start%> <end%> <duration> [easing]"
2529
+ // Example: pan-left 50% 20% 80% 2000ms ease-in
2530
+
2531
+ // Parse zoom percentage (required)
2532
+ if (parts.length > nextIndex && parts[nextIndex].includes('%')) {
2533
+ const zoomStr = parts[nextIndex].split('%')[0];
2534
+ const parsedZoom = parseFloat(zoomStr);
2535
+ if (!isNaN(parsedZoom) && parsedZoom >= 0) {
2536
+ zoom = parsedZoom; // Store as percentage (e.g., 50 for 50%)
2537
+ nextIndex++;
2538
+ }
2539
+ }
2540
+
2541
+ // Parse start position percentage (required)
2542
+ if (parts.length > nextIndex && parts[nextIndex].includes('%')) {
2543
+ const startStr = parts[nextIndex].split('%')[0];
2544
+ const parsedStart = parseFloat(startStr);
2545
+ if (!isNaN(parsedStart) && parsedStart >= 0 && parsedStart <= 100) {
2546
+ // Store in appropriate variable based on pan direction
2547
+ if (effect === 'pan-left' || effect === 'pan-right') {
2548
+ panStartX = parsedStart;
2549
+ } else {
2550
+ panStartY = parsedStart;
2551
+ }
2552
+ nextIndex++;
2553
+ }
2554
+ }
2555
+
2556
+ // Parse end position percentage (required)
2557
+ if (parts.length > nextIndex && parts[nextIndex].includes('%')) {
2558
+ const endStr = parts[nextIndex].split('%')[0];
2559
+ const parsedEnd = parseFloat(endStr);
2560
+ if (!isNaN(parsedEnd) && parsedEnd >= 0 && parsedEnd <= 100) {
2561
+ // Store in appropriate variable based on pan direction
2562
+ if (effect === 'pan-left' || effect === 'pan-right') {
2563
+ panEndX = parsedEnd;
2564
+ } else {
2565
+ panEndY = parsedEnd;
2566
+ }
2567
+ nextIndex++;
2568
+ }
2569
+ }
2570
+
2571
+ // Parse effect duration (required)
2572
+ if (parts.length > nextIndex) {
2573
+ const durationStr = parts[nextIndex];
2574
+ // Simple inline parser for "1000ms" or "1s" format
2575
+ const match = durationStr.match(/^(\d+(?:\.\d+)?)(ms|s)$/);
2576
+ if (match) {
2577
+ const value = parseFloat(match[1]);
2578
+ const parsedDuration = match[2] === 's' ? value * 1000 : value;
2579
+ if (parsedDuration > 0) {
2580
+ effectDuration = parsedDuration;
2581
+ nextIndex++;
2582
+ }
2583
+ }
2584
+ }
2585
+
2586
+ // Parse easing (optional)
2587
+ if (parts.length > nextIndex) {
2588
+ const easingStr = parts[nextIndex] as 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
2589
+ if (['linear', 'ease-in', 'ease-out', 'ease-in-out'].includes(easingStr)) {
2590
+ easing = easingStr;
2591
+ }
2592
+ }
2593
+ }
2594
+
2595
+ return {
2596
+ objectFitKenBurns: effect,
2597
+ objectFitKenBurnsZoom: zoom,
2598
+ objectFitKenBurnsEffectDuration: effectDuration,
2599
+ objectFitKenBurnsEasing: easing,
2600
+ objectFitKenBurnsFocalX: focalX,
2601
+ objectFitKenBurnsFocalY: focalY,
2602
+ objectFitKenBurnsPanStartX: panStartX,
2603
+ objectFitKenBurnsPanStartY: panStartY,
2604
+ objectFitKenBurnsPanEndX: panEndX,
2605
+ objectFitKenBurnsPanEndY: panEndY,
2606
+ };
2607
+ }
2226
2608
  }
package/src/project.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  FFmpegOption,
6
6
  Upload,
7
7
  AIProvider,
8
+ SequenceDebugInfo,
8
9
  } from './type';
9
10
  import { Label } from './ffmpeg';
10
11
  import { AssetManager } from './asset-manager';
@@ -13,11 +14,13 @@ import { FilterBuffer } from './stream';
13
14
  import { ExpressionContext, FragmentData } from './expression-parser';
14
15
  import { renderContainers } from './container-renderer';
15
16
  import { renderApps } from './app-renderer';
17
+ import { buildAppsIfNeeded } from './app-builder';
16
18
  import { dirname } from 'path';
17
19
 
18
20
  export class Project {
19
21
  private assetManager: AssetManager;
20
22
  private expressionContext: ExpressionContext;
23
+ private sequencesDebugInfo: SequenceDebugInfo[] = [];
21
24
 
22
25
  constructor(
23
26
  private sequencesDefinitions: SequenceDefinition[],
@@ -46,6 +49,8 @@ export class Project {
46
49
 
47
50
  let buf = new FilterBuffer();
48
51
  let mainSequence: Sequence | null = null;
52
+ this.sequencesDebugInfo = []; // Reset debug info
53
+ let sequenceIndex = 0;
49
54
 
50
55
  this.sequencesDefinitions.forEach((sequenceDefinition) => {
51
56
  const seq = new Sequence(
@@ -61,6 +66,14 @@ export class Project {
61
66
 
62
67
  seq.build();
63
68
 
69
+ // Collect debug info
70
+ this.sequencesDebugInfo.push({
71
+ sequenceIndex,
72
+ totalDuration: seq.getTotalDuration(),
73
+ fragments: seq.getDebugInfo(),
74
+ });
75
+ sequenceIndex++;
76
+
64
77
  if (!mainSequence) {
65
78
  mainSequence = seq;
66
79
  } else {
@@ -95,6 +108,47 @@ export class Project {
95
108
  });
96
109
  }
97
110
 
111
+ public printDebugInfo() {
112
+ console.log('\n=== Debug: Sequences & Fragments Timeline ===\n');
113
+
114
+ if (this.sequencesDebugInfo.length === 0) {
115
+ console.log('No sequences built yet.\n');
116
+ return;
117
+ }
118
+
119
+ this.sequencesDebugInfo.forEach((seqInfo) => {
120
+ console.log(`\nšŸ“¹ Sequence ${seqInfo.sequenceIndex}`);
121
+ console.log(` Total Duration: ${Math.round(seqInfo.totalDuration)}ms`);
122
+ console.log(` Fragments: ${seqInfo.fragments.length}\n`);
123
+
124
+ seqInfo.fragments.forEach((frag, index) => {
125
+ const status = frag.enabled ? 'āœ“' : 'āœ—';
126
+
127
+ // Show ID if it's meaningful (not auto-generated fragment_xxx)
128
+ const isAutoGeneratedId = frag.id.startsWith('fragment_');
129
+ const idDisplay = isAutoGeneratedId ? '' : ` id="${frag.id}"`;
130
+
131
+ console.log(` ${status} [${index + 1}]${idDisplay}`);
132
+ console.log(` Asset: ${frag.assetName}`);
133
+ console.log(` Start: ${Math.round(frag.startTime)}ms`);
134
+ console.log(` End: ${Math.round(frag.endTime)}ms`);
135
+ console.log(` Duration: ${Math.round(frag.duration)}ms`);
136
+
137
+ // Only show non-zero values
138
+ if (Math.round(frag.trimLeft) > 0) {
139
+ console.log(` Trim Left: ${Math.round(frag.trimLeft)}ms`);
140
+ }
141
+ if (Math.round(frag.overlayLeft) > 0) {
142
+ console.log(` Overlay: ${Math.round(frag.overlayLeft)}ms`);
143
+ }
144
+
145
+ console.log('');
146
+ });
147
+ });
148
+
149
+ console.log('===========================================\n');
150
+ }
151
+
98
152
  public getAssetManager(): AssetManager {
99
153
  return this.assetManager;
100
154
  }
@@ -222,10 +276,12 @@ export class Project {
222
276
  * Renders all apps and creates virtual assets for them.
223
277
  * Apps must dispatch "sts-render-complete" on document when ready,
224
278
  * or rendering will fail after a 5-second timeout.
279
+ * @param forceAppBuild - If true, rebuilds apps even if output exists
225
280
  */
226
281
  public async renderApps(
227
282
  outputName: string,
228
283
  activeCacheKeys?: Set<string>,
284
+ forceAppBuild: boolean = false,
229
285
  ): Promise<void> {
230
286
  const output = this.getOutput(outputName);
231
287
  if (!output) {
@@ -245,6 +301,12 @@ export class Project {
245
301
  const apps = fragmentsWithApps.map((frag) => frag.app!);
246
302
  const projectDir = dirname(this.projectPath);
247
303
 
304
+ // Build apps if needed (checks for dst/dist directories with package.json)
305
+ // Deduplicate app sources to avoid building the same app multiple times
306
+ const appSources = apps.map((app) => app.src);
307
+ const uniqueAppSources = [...new Set(appSources)];
308
+ await buildAppsIfNeeded(uniqueAppSources, projectDir, forceAppBuild);
309
+
248
310
  const results = await renderApps(
249
311
  apps,
250
312
  output.resolution.width,