|
@@ -1,14 +1,67 @@
|
|
|
import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
|
|
import type { ComposedProject, ExportFile, ExportManifest } from "@pipeline/shared";
|
|
|
import { isoDateString } from "@pipeline/shared";
|
|
import { isoDateString } from "@pipeline/shared";
|
|
|
-import { getTimezone } from "@pipeline/shared/node";
|
|
|
|
|
|
|
+import { getTimezone, createLogger } from "@pipeline/shared/node";
|
|
|
import { join } from "node:path";
|
|
import { join } from "node:path";
|
|
|
-import { mkdir } from "node:fs/promises";
|
|
|
|
|
|
|
+import { mkdir, writeFile } from "node:fs/promises";
|
|
|
|
|
+import { stringify as stringifyYaml } from "yaml";
|
|
|
|
|
+
|
|
|
|
|
+const log = createLogger("export");
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Per-platform × per-template publish config (from `config.publishMeta`).
|
|
|
|
|
+ * Everything except `tags` is emitted verbatim under the manifest's `category`
|
|
|
|
|
+ * block, so platform-specific partition keys (bilibili `tid`, douyin `category`,
|
|
|
|
|
+ * …) pass through without the code needing to know them.
|
|
|
|
|
+ */
|
|
|
|
|
+export interface PublishTargetConfig {
|
|
|
|
|
+ tags?: string[];
|
|
|
|
|
+ tid?: number;
|
|
|
|
|
+ category?: string;
|
|
|
|
|
+ [key: string]: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Build the sidecar manifest object for one rendered file. Exported for tests. */
|
|
|
|
|
+export function buildPublishManifest(
|
|
|
|
|
+ project: ComposedProject,
|
|
|
|
|
+ fileBasename: string,
|
|
|
|
|
+ publishMeta?: PublishTargetConfig
|
|
|
|
|
+): Record<string, unknown> {
|
|
|
|
|
+ const publish = project.publish;
|
|
|
|
|
+ const configTags = publishMeta?.tags ?? [];
|
|
|
|
|
+ const llmTags = publish?.tags ?? [];
|
|
|
|
|
+ // Merge LLM tags first, then config tags; drop empties + dedupe (case-sensitive).
|
|
|
|
|
+ const tags = [...llmTags, ...configTags]
|
|
|
|
|
+ .filter((t): t is string => typeof t === "string" && t.length > 0)
|
|
|
|
|
+ .filter((t, i, arr) => arr.indexOf(t) === i);
|
|
|
|
|
+
|
|
|
|
|
+ // category = everything in publishMeta except `tags` (tid / category / …).
|
|
|
|
|
+ const category: Record<string, unknown> = {};
|
|
|
|
|
+ if (publishMeta) {
|
|
|
|
|
+ for (const [k, v] of Object.entries(publishMeta)) {
|
|
|
|
|
+ if (k !== "tags") category[k] = v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const manifest: Record<string, unknown> = {
|
|
|
|
|
+ title: publish?.title || project.title || "",
|
|
|
|
|
+ description: publish?.description || "",
|
|
|
|
|
+ tags,
|
|
|
|
|
+ platform: project.platform,
|
|
|
|
|
+ template: project.template,
|
|
|
|
|
+ file: `${fileBasename}.mp4`,
|
|
|
|
|
+ durationSeconds: Number((project.durationInFrames / project.fps).toFixed(2)),
|
|
|
|
|
+ date: isoDateString(new Date(), getTimezone()),
|
|
|
|
|
+ };
|
|
|
|
|
+ if (Object.keys(category).length > 0) manifest.category = category;
|
|
|
|
|
+ return manifest;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
export async function exportVideo(
|
|
export async function exportVideo(
|
|
|
renderedPath: string,
|
|
renderedPath: string,
|
|
|
project: ComposedProject,
|
|
project: ComposedProject,
|
|
|
outputDir: string,
|
|
outputDir: string,
|
|
|
- jobId: string
|
|
|
|
|
|
|
+ jobId: string,
|
|
|
|
|
+ publishMeta?: PublishTargetConfig
|
|
|
): Promise<ExportManifest> {
|
|
): Promise<ExportManifest> {
|
|
|
// Unified layout: {outputDir}/{template}/{ISO-date}/{file}. Date is in the
|
|
// Unified layout: {outputDir}/{template}/{ISO-date}/{file}. Date is in the
|
|
|
// configured timezone (default Asia/Shanghai) so the folder matches the date
|
|
// configured timezone (default Asia/Shanghai) so the folder matches the date
|
|
@@ -19,10 +72,22 @@ export async function exportVideo(
|
|
|
const { stat, copyFile } = await import("node:fs/promises");
|
|
const { stat, copyFile } = await import("node:fs/promises");
|
|
|
const statResult = await stat(renderedPath);
|
|
const statResult = await stat(renderedPath);
|
|
|
|
|
|
|
|
- const filename = `${project.template}-${project.platform}-${jobId.slice(0, 8)}.mp4`;
|
|
|
|
|
|
|
+ const fileBasename = `${project.template}-${project.platform}-${jobId.slice(0, 8)}`;
|
|
|
|
|
+ const filename = `${fileBasename}.mp4`;
|
|
|
const finalPath = join(subDir, filename);
|
|
const finalPath = join(subDir, filename);
|
|
|
await copyFile(renderedPath, finalPath);
|
|
await copyFile(renderedPath, finalPath);
|
|
|
|
|
|
|
|
|
|
+ // Sidecar publish manifest (YAML), same basename as the MP4. Best-effort: a
|
|
|
|
|
+ // manifest failure must never abort a successful render.
|
|
|
|
|
+ try {
|
|
|
|
|
+ const manifestPath = join(subDir, `${fileBasename}.yaml`);
|
|
|
|
|
+ const manifest = buildPublishManifest(project, fileBasename, publishMeta);
|
|
|
|
|
+ await writeFile(manifestPath, stringifyYaml(manifest), "utf8");
|
|
|
|
|
+ log.info(`publish manifest -> ${manifestPath}`);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ log.warn(`publish manifest write failed (render still succeeded): ${err instanceof Error ? err.message : err}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const files: ExportFile[] = [
|
|
const files: ExportFile[] = [
|
|
|
{
|
|
{
|
|
|
platform: project.platform,
|
|
platform: project.platform,
|