diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-02 13:13:07 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-02 13:13:07 +0100 |
| commit | e7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch) | |
| tree | 74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/services | |
| parent | 93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff) | |
| download | opencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz | |
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements:
* **Clip Effects**:
* Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder).
* Implemented dynamic parameter fields for effects.
* Added support for keyframing effect parameters.
* **Assets Panel**:
* Added sorting options: Name, Type, Duration, and File Size.
* Persisted view preferences (grid/list mode, sort order) to local storage.
* Refactored media item rendering and drag interactions.
* **Timeline & Interaction**:
* **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element.
* **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps).
* **Visuals**: Implemented tiled background rendering for video/image clips on the timeline.
* **Shortcuts**: Added "Deselect All" action bound to the `Escape` key.
* **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled.
* **Text Elements**:
* Refactored text background storage to use an explicit `enabled` flag.
* Added `V8toV9` storage migration to update existing projects.
* **Architecture**:
* Moved export state management to `ProjectManager` for better lifecycle handling.
* Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
Diffstat (limited to 'apps/web/src/services')
7 files changed, 450 insertions, 84 deletions
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 0ddb4eae..ffffc895 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,10 +1,13 @@ import type { CanvasRenderer } from "../canvas-renderer"; +import { createOffscreenCanvas } from "../canvas-utils"; import { BaseNode } from "./base-node"; import type { TextElement } from "@/types/timeline"; import { DEFAULT_TEXT_ELEMENT, DEFAULT_LINE_HEIGHT, FONT_SIZE_SCALE_REFERENCE, + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, } from "@/constants/text-constants"; import { getMetricAscent, @@ -17,6 +20,10 @@ import { resolveOpacityAtTime, resolveTransformAtTime, } from "@/lib/animation"; +import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; +import { getEffect } from "@/lib/effects"; +import { webglEffectRenderer } from "../webgl-effect-renderer"; +import { clamp } from "@/utils/math"; function scaleFontSize({ fontSize, @@ -32,6 +39,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { return `"${fontFamily.replace(/"/g, '\\"')}"`; } +const TEXT_DECORATION_THICKNESS_RATIO = 0.07; +const STRIKETHROUGH_VERTICAL_RATIO = 0.35; + function drawTextDecoration({ ctx, textDecoration, @@ -51,7 +61,7 @@ function drawTextDecoration({ }): void { if (textDecoration === "none" || !textDecoration) return; - const thickness = Math.max(1, scaledFontSize * 0.07); + const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO); const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize }); @@ -65,7 +75,7 @@ function drawTextDecoration({ } if (textDecoration === "line-through") { - const strikeY = lineY - (ascent - descent) * 0.35; + const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO; ctx.fillRect(xStart, strikeY, lineWidth, thickness); } } @@ -89,8 +99,6 @@ export class TextNode extends BaseNode<TextNodeParams> { return; } - renderer.context.save(); - const localTime = getElementLocalTime({ timelineTime: time, elementStartTime: this.params.startTime, @@ -106,15 +114,10 @@ export class TextNode extends BaseNode<TextNodeParams> { animations: this.params.animations, localTime, }); + const x = transform.position.x + this.params.canvasCenter.x; const y = transform.position.y + this.params.canvasCenter.y; - renderer.context.translate(x, y); - renderer.context.scale(transform.scale, transform.scale); - if (transform.rotate) { - renderer.context.rotate((transform.rotate * Math.PI) / 180); - } - const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal"; const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal"; const scaledFontSize = scaleFontSize({ @@ -122,83 +125,147 @@ export class TextNode extends BaseNode<TextNodeParams> { canvasHeight: this.params.canvasHeight, }); const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily }); - renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - renderer.context.textAlign = this.params.textAlign; - renderer.context.fillStyle = this.params.color; - + const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; const letterSpacing = this.params.letterSpacing ?? 0; const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT; - if ("letterSpacing" in renderer.context) { - ( - renderer.context as CanvasRenderingContext2D & { letterSpacing: string } - ).letterSpacing = `${letterSpacing}px`; - } - const lines = this.params.content.split("\n"); const lineHeightPx = scaledFontSize * lineHeight; const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; const baseline = this.params.textBaseline ?? "middle"; + const blendMode = ( + this.params.blendMode && this.params.blendMode !== "normal" + ? this.params.blendMode + : "source-over" + ) as GlobalCompositeOperation; + renderer.context.save(); + renderer.context.font = fontString; renderer.context.textBaseline = baseline; + if ("letterSpacing" in renderer.context) { + (renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`; + } const lineMetrics = lines.map((line) => renderer.context.measureText(line)); + renderer.context.restore(); + const lineCount = lines.length; + const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize }); - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize: scaledFontSize, - }); + const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => { + ctx.font = fontString; + ctx.textAlign = this.params.textAlign; + ctx.textBaseline = baseline; + ctx.fillStyle = this.params.color; + if ("letterSpacing" in ctx) { + (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`; + } - const prevAlpha = renderer.context.globalAlpha; - renderer.context.globalCompositeOperation = ( - this.params.blendMode && this.params.blendMode !== "normal" - ? this.params.blendMode - : "source-over" - ) as GlobalCompositeOperation; - renderer.context.globalAlpha = opacity; + if ( + this.params.background.enabled && + this.params.background.color && + this.params.background.color !== "transparent" && + lineCount > 0 + ) { + const { color, cornerRadius = 0 } = this.params.background; + const backgroundRect = getTextBackgroundRect({ + textAlign: this.params.textAlign, + block, + background: this.params.background, + fontSizeRatio, + }); + if (backgroundRect) { + const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100; + const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius); + ctx.fill(); + ctx.fillStyle = this.params.color; + } + } - if ( - this.params.background.color && - this.params.background.color !== "transparent" && - lineCount > 0 - ) { - const { color, cornerRadius = 0 } = this.params.background; - const backgroundRect = getTextBackgroundRect({ - textAlign: this.params.textAlign, - block, - background: this.params.background, - fontSizeRatio, - }); - if (backgroundRect) { - renderer.context.fillStyle = color; - renderer.context.beginPath(); - renderer.context.roundRect( - backgroundRect.left, - backgroundRect.top, - backgroundRect.width, - backgroundRect.height, - cornerRadius, - ); - renderer.context.fill(); - renderer.context.fillStyle = this.params.color; + for (let i = 0; i < lineCount; i++) { + const lineY = i * lineHeightPx - block.visualCenterOffset; + ctx.fillText(lines[i], 0, lineY); + drawTextDecoration({ + ctx, + textDecoration: this.params.textDecoration ?? "none", + lineWidth: lineMetrics[i].width, + lineY, + metrics: lineMetrics[i], + scaledFontSize, + textAlign: this.params.textAlign, + }); + } + }; + + const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => { + ctx.translate(x, y); + ctx.scale(transform.scale, transform.scale); + if (transform.rotate) { + ctx.rotate((transform.rotate * Math.PI) / 180); } + }; + + const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? []; + + if (enabledEffects.length === 0) { + renderer.context.save(); + applyTransform(renderer.context); + renderer.context.globalCompositeOperation = blendMode; + renderer.context.globalAlpha = opacity; + drawContent(renderer.context); + renderer.context.restore(); + return; + } + + // Effects path: render text to a same-size offscreen canvas so the blur + // can spread into the surrounding transparent area without hard clipping. + const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height }); + const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null; + + if (!offscreenCtx) { + renderer.context.save(); + applyTransform(renderer.context); + renderer.context.globalCompositeOperation = blendMode; + renderer.context.globalAlpha = opacity; + drawContent(renderer.context); + renderer.context.restore(); + return; } - for (let i = 0; i < lineCount; i++) { - const y = i * lineHeightPx - block.visualCenterOffset; - renderer.context.fillText(lines[i], 0, y); - drawTextDecoration({ - ctx: renderer.context, - textDecoration: this.params.textDecoration ?? "none", - lineWidth: lineMetrics[i].width, - lineY: y, - metrics: lineMetrics[i], - scaledFontSize, - textAlign: this.params.textAlign, + offscreenCtx.save(); + applyTransform(offscreenCtx); + drawContent(offscreenCtx); + offscreenCtx.restore(); + + let currentSource: CanvasImageSource = offscreen; + for (const effect of enabledEffects) { + const resolvedParams = resolveEffectParamsAtTime({ + effect, + animations: this.params.animations, + localTime, + }); + const definition = getEffect({ effectType: effect.type }); + const passes = definition.renderer.passes.map((pass) => ({ + fragmentShader: pass.fragmentShader, + uniforms: pass.uniforms({ + effectParams: resolvedParams, + width: renderer.width, + height: renderer.height, + }), + })); + currentSource = webglEffectRenderer.applyEffect({ + source: currentSource, + width: renderer.width, + height: renderer.height, + passes, }); } - renderer.context.globalAlpha = prevAlpha; + renderer.context.save(); + renderer.context.globalCompositeOperation = blendMode; + renderer.context.globalAlpha = opacity; + renderer.context.drawImage(currentSource, 0, 0); renderer.context.restore(); } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 15a7b081..6c50aa12 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -10,6 +10,7 @@ import { resolveOpacityAtTime, resolveTransformAtTime, } from "@/lib/animation"; +import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { getEffect } from "@/lib/effects"; import { webglEffectRenderer } from "../webgl-effect-renderer"; @@ -127,11 +128,16 @@ export abstract class VisualNode< let currentResult: CanvasImageSource = elementCanvas; for (const effect of enabledEffects) { + const resolvedParams = resolveEffectParamsAtTime({ + effect, + animations: this.params.animations, + localTime: animationLocalTime, + }); const definition = getEffect({ effectType: effect.type }); const passes = definition.renderer.passes.map((pass) => ({ fragmentShader: pass.fragmentShader, uniforms: pass.uniforms({ - effectParams: effect.params, + effectParams: resolvedParams, width: scaledWidth, height: scaledHeight, }), diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts new file mode 100644 index 00000000..51e91f78 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV8ToV9 } from "../transformers/v8-to-v9"; + +const v8ProjectWithText = { + id: "project-v8-text", + version: 8, + metadata: { + id: "project-v8-text", + name: "V8 Project with Text", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + settings: { + fps: 30, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + currentSceneId: "scene-main", + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "el-1", + type: "text", + content: "With color", + startTime: 0, + duration: 5, + background: { + color: "#ff0000", + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + }, + }, + { + id: "el-2", + type: "text", + content: "Transparent", + startTime: 5, + duration: 5, + background: { + color: "transparent", + paddingX: 30, + paddingY: 42, + }, + }, + ], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], +} as Parameters<typeof transformProjectV8ToV9>[0]["project"]; + +describe("V8 to V9 Migration", () => { + test("adds background.enabled from color (transparent => false, otherwise true)", () => { + const result = transformProjectV8ToV9({ project: v8ProjectWithText }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(9); + + const track = ( + result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> + )[0].tracks[0]; + const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>; + + expect(elements[0].background.enabled).toBe(true); + expect(elements[0].background.color).toBe("#ff0000"); + + expect(elements[1].background.enabled).toBe(false); + expect(elements[1].background.color).toBe("transparent"); + }); + + test("preserves existing background.enabled if already present", () => { + const projectWithEnabled = { + ...v8ProjectWithText, + scenes: [ + { + ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0], + tracks: [ + { + id: "track-text", + type: "text", + name: "Text Track", + hidden: false, + elements: [ + { + id: "el-1", + type: "text", + content: "Already has enabled", + startTime: 0, + duration: 5, + background: { + enabled: false, + color: "#00ff00", + }, + }, + ], + }, + ], + }, + ], + } as Parameters<typeof transformProjectV8ToV9>[0]["project"]; + + const result = transformProjectV8ToV9({ project: projectWithEnabled }); + + expect(result.skipped).toBe(false); + const elements = ( + result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }> + )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>; + expect(elements[0].background.enabled).toBe(false); + }); + + test("skips non-text elements and tracks", () => { + const projectWithVideoOnly = { + ...v8ProjectWithText, + scenes: [ + { + id: "scene-main", + name: "Main scene", + isMain: true, + tracks: [ + { + id: "track-video", + type: "video", + name: "Video Track", + isMain: true, + elements: [], + }, + ], + bookmarks: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ], + } as Parameters<typeof transformProjectV8ToV9>[0]["project"]; + + const result = transformProjectV8ToV9({ project: projectWithVideoOnly }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(9); + }); + + test("skips projects that are already v9", () => { + const result = transformProjectV8ToV9({ + project: { ...v8ProjectWithText, version: 9 }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v9"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV8ToV9({ + project: { + version: 8, + scenes: [], + } as Parameters<typeof transformProjectV8ToV9>[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 86a202b2..bffacb66 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -7,10 +7,11 @@ import { V4toV5Migration } from "./v4-to-v5"; import { V5toV6Migration } from "./v5-to-v6"; import { V6toV7Migration } from "./v6-to-v7"; import { V7toV8Migration } from "./v7-to-v8"; +import { V8toV9Migration } from "./v8-to-v9"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 8; +export const CURRENT_PROJECT_VERSION = 9; export const migrations = [ new V0toV1Migration(), @@ -21,4 +22,5 @@ export const migrations = [ new V5toV6Migration(), new V6toV7Migration(), new V7toV8Migration(), + new V8toV9Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index d16106d9..297d1c58 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -388,7 +388,7 @@ async function transformMediaTrack({ ); const validElements = transformedElements.filter( - (el): el is VideoElement | ImageElement => el !== null, + (element): element is VideoElement | ImageElement => element !== null, ); return { @@ -450,17 +450,18 @@ function transformTextTrack({ value: textElement.color, fallback: "#000000", }), - background: { - color: getStringValue({ - value: textElement.backgroundColor, - fallback: "transparent", - }), - cornerRadius: 0, - paddingX: 8, - paddingY: 4, - offsetX: 0, - offsetY: 0, - }, + background: { + enabled: false, + color: getStringValue({ + value: textElement.backgroundColor, + fallback: "transparent", + }), + cornerRadius: 0, + paddingX: 8, + paddingY: 4, + offsetX: 0, + offsetY: 0, + }, textAlign: (getStringValue({ value: textElement.textAlign, fallback: "left", @@ -486,7 +487,7 @@ function transformTextTrack({ trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), }; }) - .filter((el): el is TextElement => el !== null); + .filter((element): element is TextElement => element !== null); return { id: getStringValue({ value: track.id, fallback: "" }), @@ -529,7 +530,7 @@ function transformAudioTrack({ trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), }; }) - .filter((el): el is AudioElement => el !== null); + .filter((element): element is AudioElement => element !== null); return { id: getStringValue({ value: track.id, fallback: "" }), diff --git a/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts new file mode 100644 index 00000000..1dc895bc --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts @@ -0,0 +1,99 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV8ToV9({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + const projectId = getProjectId({ project }); + if (!projectId) { + return { project, skipped: true, reason: "no project id" }; + } + + if (isV9Project({ project })) { + return { project, skipped: true, reason: "already v9" }; + } + + const migratedProject = migrateProjectTextElements({ project }); + + return { + project: { ...migratedProject, version: 9 }, + skipped: false, + }; +} + +function migrateProjectTextElements({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenesValue = project.scenes; + if (!Array.isArray(scenesValue)) return project; + + let hasChanges = false; + const migratedScenes = scenesValue.map((scene) => { + const migrated = migrateSceneTextElements({ scene }); + if (migrated !== scene) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return project; + return { ...project, scenes: migratedScenes }; +} + +function migrateSceneTextElements({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) return scene; + + const tracksValue = scene.tracks; + if (!Array.isArray(tracksValue)) return scene; + + let hasChanges = false; + const migratedTracks = tracksValue.map((track) => { + const migrated = migrateTrackTextElements({ track }); + if (migrated !== track) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return scene; + return { ...scene, tracks: migratedTracks }; +} + +function migrateTrackTextElements({ track }: { track: unknown }): unknown { + if (!isRecord(track)) return track; + if (track.type !== "text") return track; + + const elementsValue = track.elements; + if (!Array.isArray(elementsValue)) return track; + + let hasChanges = false; + const migratedElements = elementsValue.map((element) => { + const migrated = migrateTextElement({ element }); + if (migrated !== element) hasChanges = true; + return migrated; + }); + + if (!hasChanges) return track; + return { ...track, elements: migratedElements }; +} + +function migrateTextElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) return element; + if (element.type !== "text") return element; + + const bg = element.background; + if (!isRecord(bg)) return element; + if (typeof bg.enabled === "boolean") return element; + + const color = typeof bg.color === "string" ? bg.color : "transparent"; + const enabled = color !== "transparent"; + + return { + ...element, + background: { ...bg, enabled }, + }; +} + +function isV9Project({ project }: { project: ProjectRecord }): boolean { + return typeof project.version === "number" && project.version >= 9; +} diff --git a/apps/web/src/services/storage/migrations/v8-to-v9.ts b/apps/web/src/services/storage/migrations/v8-to-v9.ts new file mode 100644 index 00000000..6222eeac --- /dev/null +++ b/apps/web/src/services/storage/migrations/v8-to-v9.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV8ToV9 } from "./transformers/v8-to-v9"; + +export class V8toV9Migration extends StorageMigration { + from = 8; + to = 9; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV8ToV9({ project }); + } +} |
