Spaces:
Sleeping
Sleeping
Render reviews as HD social exports
Browse files
src/app/admin/reviews/page.tsx
CHANGED
|
@@ -116,6 +116,10 @@ function PhoneVideoPreview({ src }: { src: string }) {
|
|
| 116 |
);
|
| 117 |
}
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
export default async function AdminReviewsPage() {
|
| 120 |
const { submissions, sourceLabel } = await listReviewsForAdminPage();
|
| 121 |
const recorderHref = remoteRecorderHref();
|
|
@@ -211,6 +215,14 @@ export default async function AdminReviewsPage() {
|
|
| 211 |
<div className="rounded-md bg-cream/[0.04] px-3 py-2 text-sm leading-5 text-cream/70">
|
| 212 |
{submission.feedback}
|
| 213 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
</article>
|
|
|
|
| 116 |
);
|
| 117 |
}
|
| 118 |
|
| 119 |
+
function downloadFilename(submission: AdminReviewSubmission) {
|
| 120 |
+
return `${submission.submissionId}.mp4`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
export default async function AdminReviewsPage() {
|
| 124 |
const { submissions, sourceLabel } = await listReviewsForAdminPage();
|
| 125 |
const recorderHref = remoteRecorderHref();
|
|
|
|
| 215 |
<div className="rounded-md bg-cream/[0.04] px-3 py-2 text-sm leading-5 text-cream/70">
|
| 216 |
{submission.feedback}
|
| 217 |
</div>
|
| 218 |
+
|
| 219 |
+
<a
|
| 220 |
+
href={submission.previewUrl}
|
| 221 |
+
download={downloadFilename(submission)}
|
| 222 |
+
className="inline-flex h-10 items-center justify-center rounded-full border border-sage/40 bg-sage/15 px-4 text-sm font-semibold text-sage transition hover:bg-sage/25"
|
| 223 |
+
>
|
| 224 |
+
Download HD MP4
|
| 225 |
+
</a>
|
| 226 |
</div>
|
| 227 |
</div>
|
| 228 |
</article>
|
src/lib/server/serverClipRenderer.ts
CHANGED
|
@@ -20,8 +20,8 @@ export type ServerRenderResult = {
|
|
| 20 |
};
|
| 21 |
|
| 22 |
const WORK_DIR = path.join(process.cwd(), '.local-review-data', 'server-renders');
|
| 23 |
-
const VIDEO_WIDTH =
|
| 24 |
-
const VIDEO_HEIGHT =
|
| 25 |
const VIDEO_FPS = 24;
|
| 26 |
const MIN_VIDEO_CLIP_SECONDS = 5;
|
| 27 |
const MAX_VIDEO_CLIP_SECONDS = 7;
|
|
@@ -176,7 +176,15 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
|
|
| 176 |
);
|
| 177 |
const baseVideoDurationSeconds = cappedVideoDurations.reduce((total, duration) => total + duration, 0);
|
| 178 |
const renderTargetSeconds =
|
| 179 |
-
hasVoiceover
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
const loopFrameCount = Math.max(1, Math.ceil(baseVideoDurationSeconds * VIDEO_FPS));
|
| 181 |
|
| 182 |
const inputArgs = [
|
|
@@ -226,11 +234,12 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
|
|
| 226 |
? `${audioInputs}concat=n=${audioPaths.length}:v=0:a=1[acat]`
|
| 227 |
: '';
|
| 228 |
const audioFinalize = audioPaths.length
|
| 229 |
-
? `[acat]atrim=duration=${
|
|
|
|
| 230 |
: '';
|
| 231 |
|
| 232 |
-
// Cap every voiceover render to a short-form 17 seconds
|
| 233 |
-
//
|
| 234 |
const filterComplex = [videoFilters, videoConcat, videoFinalize, audioFilters, audioConcat, audioFinalize]
|
| 235 |
.filter(Boolean)
|
| 236 |
.join(';');
|
|
@@ -257,7 +266,9 @@ export async function renderClipsOnServer(input: ServerRenderInput): Promise<Ser
|
|
| 257 |
'30',
|
| 258 |
'-r',
|
| 259 |
String(VIDEO_FPS),
|
| 260 |
-
...(audioPaths.length ? ['-
|
|
|
|
|
|
|
| 261 |
'-movflags',
|
| 262 |
'+faststart',
|
| 263 |
'-avoid_negative_ts',
|
|
|
|
| 20 |
};
|
| 21 |
|
| 22 |
const WORK_DIR = path.join(process.cwd(), '.local-review-data', 'server-renders');
|
| 23 |
+
const VIDEO_WIDTH = 1080;
|
| 24 |
+
const VIDEO_HEIGHT = 1920;
|
| 25 |
const VIDEO_FPS = 24;
|
| 26 |
const MIN_VIDEO_CLIP_SECONDS = 5;
|
| 27 |
const MAX_VIDEO_CLIP_SECONDS = 7;
|
|
|
|
| 176 |
);
|
| 177 |
const baseVideoDurationSeconds = cappedVideoDurations.reduce((total, duration) => total + duration, 0);
|
| 178 |
const renderTargetSeconds =
|
| 179 |
+
hasVoiceover
|
| 180 |
+
? Math.max(
|
| 181 |
+
1,
|
| 182 |
+
Math.min(
|
| 183 |
+
FINAL_VIDEO_MAX_SECONDS,
|
| 184 |
+
audioDurationSeconds > 0 ? audioDurationSeconds : FINAL_VIDEO_MAX_SECONDS,
|
| 185 |
+
),
|
| 186 |
+
)
|
| 187 |
+
: Math.max(1, baseVideoDurationSeconds);
|
| 188 |
const loopFrameCount = Math.max(1, Math.ceil(baseVideoDurationSeconds * VIDEO_FPS));
|
| 189 |
|
| 190 |
const inputArgs = [
|
|
|
|
| 234 |
? `${audioInputs}concat=n=${audioPaths.length}:v=0:a=1[acat]`
|
| 235 |
: '';
|
| 236 |
const audioFinalize = audioPaths.length
|
| 237 |
+
? `[acat]atrim=duration=${formatDuration(renderTargetSeconds)},` +
|
| 238 |
+
`apad=whole_dur=${formatDuration(renderTargetSeconds)},asetpts=PTS-STARTPTS[a]`
|
| 239 |
: '';
|
| 240 |
|
| 241 |
+
// Cap every voiceover render to a short-form 17 seconds, then make audio
|
| 242 |
+
// and video share one target duration so playback never freezes under speech.
|
| 243 |
const filterComplex = [videoFilters, videoConcat, videoFinalize, audioFilters, audioConcat, audioFinalize]
|
| 244 |
.filter(Boolean)
|
| 245 |
.join(';');
|
|
|
|
| 266 |
'30',
|
| 267 |
'-r',
|
| 268 |
String(VIDEO_FPS),
|
| 269 |
+
...(audioPaths.length ? ['-c:a', 'aac', '-b:a', '96k'] : ['-an']),
|
| 270 |
+
'-t',
|
| 271 |
+
formatDuration(renderTargetSeconds),
|
| 272 |
'-movflags',
|
| 273 |
'+faststart',
|
| 274 |
'-avoid_negative_ts',
|